initial commit
This commit is contained in:
60
apps/Frontend/src/App.jsx
Executable file
60
apps/Frontend/src/App.jsx
Executable file
@@ -0,0 +1,60 @@
|
||||
import { Switch, Route, Redirect } from "wouter";
|
||||
import React, { lazy, Suspense } from "react";
|
||||
import { Provider } from "react-redux";
|
||||
import { store } from "./redux/store";
|
||||
import { queryClient } from "./lib/queryClient";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { Toaster } from "./components/ui/toaster";
|
||||
import { TooltipProvider } from "./components/ui/tooltip";
|
||||
import { ProtectedRoute } from "./lib/protected-route";
|
||||
import { AuthProvider } from "./hooks/use-auth";
|
||||
import Dashboard from "./pages/dashboard";
|
||||
import LoadingScreen from "./components/ui/LoadingScreen";
|
||||
const AuthPage = lazy(() => import("./pages/auth-page"));
|
||||
const PatientConnectionPage = lazy(() => import("./pages/patient-connection-page"));
|
||||
const AppointmentsPage = lazy(() => import("./pages/appointments-page"));
|
||||
const PatientsPage = lazy(() => import("./pages/patients-page"));
|
||||
const SettingsPage = lazy(() => import("./pages/settings-page"));
|
||||
const ClaimsPage = lazy(() => import("./pages/claims-page"));
|
||||
const PaymentsPage = lazy(() => import("./pages/payments-page"));
|
||||
const InsuranceStatusPage = lazy(() => import("./pages/insurance-status-page"));
|
||||
const DocumentPage = lazy(() => import("./pages/documents-page"));
|
||||
const DatabaseManagementPage = lazy(() => import("./pages/database-management-page"));
|
||||
const ReportsPage = lazy(() => import("./pages/reports-page"));
|
||||
const CloudStoragePage = lazy(() => import("./pages/cloud-storage-page"));
|
||||
const NotFound = lazy(() => import("./pages/not-found"));
|
||||
function Router() {
|
||||
return (<Switch>
|
||||
<ProtectedRoute path="/" component={() => <Redirect to="/insurance-status"/>}/>
|
||||
|
||||
<ProtectedRoute path="/dashboard" component={() => <Dashboard />}/>
|
||||
<ProtectedRoute path="/patient-connection" component={() => <PatientConnectionPage />}/>
|
||||
<ProtectedRoute path="/appointments" component={() => <AppointmentsPage />}/>
|
||||
<ProtectedRoute path="/patients" component={() => <PatientsPage />}/>
|
||||
<ProtectedRoute path="/settings" component={() => <SettingsPage />}/>
|
||||
<ProtectedRoute path="/claims" component={() => <ClaimsPage />}/>
|
||||
<ProtectedRoute path="/insurance-status" component={() => <InsuranceStatusPage />}/>
|
||||
<ProtectedRoute path="/payments" component={() => <PaymentsPage />}/>
|
||||
<ProtectedRoute path="/documents" component={() => <DocumentPage />}/>
|
||||
<ProtectedRoute path="/database-management" component={() => <DatabaseManagementPage />}/>
|
||||
<ProtectedRoute path="/reports" component={() => <ReportsPage />}/>
|
||||
<ProtectedRoute path="/cloud-storage" component={() => <CloudStoragePage />}/>
|
||||
<Route path="/auth" component={() => <AuthPage />}/>
|
||||
<Route component={() => <NotFound />}/>
|
||||
</Switch>);
|
||||
}
|
||||
function App() {
|
||||
return (<Provider store={store}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider>
|
||||
<TooltipProvider>
|
||||
<Toaster />
|
||||
<Suspense fallback={<LoadingScreen />}>
|
||||
<Router />
|
||||
</Suspense>
|
||||
</TooltipProvider>
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
</Provider>);
|
||||
}
|
||||
export default App;
|
||||
81
apps/Frontend/src/App.tsx
Executable file
81
apps/Frontend/src/App.tsx
Executable file
@@ -0,0 +1,81 @@
|
||||
import { Switch, Route, Redirect } from "wouter";
|
||||
import React, { lazy, Suspense } from "react";
|
||||
import { Provider } from "react-redux";
|
||||
import { store } from "./redux/store";
|
||||
import { queryClient } from "./lib/queryClient";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { Toaster } from "./components/ui/toaster";
|
||||
import { TooltipProvider } from "./components/ui/tooltip";
|
||||
import { ProtectedRoute } from "./lib/protected-route";
|
||||
import { AuthProvider } from "./hooks/use-auth";
|
||||
import Dashboard from "./pages/dashboard";
|
||||
import LoadingScreen from "./components/ui/LoadingScreen";
|
||||
|
||||
const AuthPage = lazy(() => import("./pages/auth-page"));
|
||||
const PatientConnectionPage = lazy(() => import("./pages/patient-connection-page"));
|
||||
const AppointmentsPage = lazy(() => import("./pages/appointments-page"));
|
||||
const PatientsPage = lazy(() => import("./pages/patients-page"));
|
||||
const SettingsPage = lazy(() => import("./pages/settings-page"));
|
||||
const ClaimsPage = lazy(() => import("./pages/claims-page"));
|
||||
const PaymentsPage = lazy(() => import("./pages/payments-page"));
|
||||
const InsuranceStatusPage = lazy(
|
||||
() => import("./pages/insurance-status-page")
|
||||
);
|
||||
const DocumentPage = lazy(() => import("./pages/documents-page"));
|
||||
const DatabaseManagementPage = lazy(
|
||||
() => import("./pages/database-management-page")
|
||||
);
|
||||
const ReportsPage = lazy(() => import("./pages/reports-page"));
|
||||
const CloudStoragePage = lazy(() => import("./pages/cloud-storage-page"));
|
||||
const NotFound = lazy(() => import("./pages/not-found"));
|
||||
|
||||
function Router() {
|
||||
return (
|
||||
<Switch>
|
||||
<ProtectedRoute path="/" component={() => <Redirect to="/insurance-status" />} />
|
||||
|
||||
<ProtectedRoute path="/dashboard" component={() => <Dashboard />} />
|
||||
<ProtectedRoute path="/patient-connection" component={() => <PatientConnectionPage />} />
|
||||
<ProtectedRoute
|
||||
path="/appointments"
|
||||
component={() => <AppointmentsPage />}
|
||||
/>
|
||||
<ProtectedRoute path="/patients" component={() => <PatientsPage />} />
|
||||
<ProtectedRoute path="/settings" component={() => <SettingsPage />} />
|
||||
<ProtectedRoute path="/claims" component={() => <ClaimsPage />} />
|
||||
<ProtectedRoute
|
||||
path="/insurance-status"
|
||||
component={() => <InsuranceStatusPage />}
|
||||
/>
|
||||
<ProtectedRoute path="/payments" component={() => <PaymentsPage />} />
|
||||
<ProtectedRoute path="/documents" component={() => <DocumentPage />} />
|
||||
<ProtectedRoute
|
||||
path="/database-management"
|
||||
component={() => <DatabaseManagementPage />}
|
||||
/>
|
||||
<ProtectedRoute path="/reports" component={() => <ReportsPage />} />
|
||||
<ProtectedRoute path="/cloud-storage" component={() => <CloudStoragePage />} />
|
||||
<Route path="/auth" component={() => <AuthPage />} />
|
||||
<Route component={() => <NotFound />} />
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider>
|
||||
<TooltipProvider>
|
||||
<Toaster />
|
||||
<Suspense fallback={<LoadingScreen />}>
|
||||
<Router />
|
||||
</Suspense>
|
||||
</TooltipProvider>
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
</Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
1191
apps/Frontend/src/assets/data/procedureCodes.json
Executable file
1191
apps/Frontend/src/assets/data/procedureCodes.json
Executable file
File diff suppressed because it is too large
Load Diff
92
apps/Frontend/src/components/analytics/appointments-by-day.tsx
Executable file
92
apps/Frontend/src/components/analytics/appointments-by-day.tsx
Executable file
@@ -0,0 +1,92 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
} from "recharts";
|
||||
|
||||
interface AppointmentsByDayProps {
|
||||
appointments: any[];
|
||||
}
|
||||
|
||||
export function AppointmentsByDay({ appointments }: AppointmentsByDayProps) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
70
apps/Frontend/src/components/analytics/new-patients.tsx
Executable file
70
apps/Frontend/src/components/analytics/new-patients.tsx
Executable file
@@ -0,0 +1,70 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, ResponsiveContainer, Tooltip } from "recharts";
|
||||
|
||||
interface NewPatientsProps {
|
||||
patients: any[];
|
||||
}
|
||||
|
||||
export function NewPatients({ patients }: NewPatientsProps) {
|
||||
// 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,571 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
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 {
|
||||
CODE_MAP,
|
||||
getPriceForCodeWithAgeFromMap,
|
||||
} from "@/utils/procedureCombosMapping";
|
||||
import { Patient, AppointmentProcedure } from "@repo/db/types";
|
||||
import { useLocation } from "wouter";
|
||||
import { DeleteConfirmationDialog } from "../ui/deleteDialog";
|
||||
import {
|
||||
DirectComboButtons,
|
||||
RegularComboButtons,
|
||||
} from "@/components/procedure/procedure-combo-buttons";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
appointmentId: number;
|
||||
patientId: number;
|
||||
patient: Patient;
|
||||
}
|
||||
|
||||
export function AppointmentProceduresDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
appointmentId,
|
||||
patientId,
|
||||
patient,
|
||||
}: Props) {
|
||||
const { toast } = useToast();
|
||||
|
||||
// -----------------------------
|
||||
// state for manual add
|
||||
// -----------------------------
|
||||
const [manualCode, setManualCode] = useState("");
|
||||
const [manualLabel, setManualLabel] = useState("");
|
||||
const [manualFee, setManualFee] = useState("");
|
||||
const [manualTooth, setManualTooth] = useState("");
|
||||
const [manualSurface, setManualSurface] = useState("");
|
||||
|
||||
// -----------------------------
|
||||
// state for inline edit
|
||||
// -----------------------------
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [editRow, setEditRow] = useState<Partial<AppointmentProcedure>>({});
|
||||
const [clearAllOpen, setClearAllOpen] = useState(false);
|
||||
|
||||
// for redirection to claim submission
|
||||
const [, setLocation] = useLocation();
|
||||
|
||||
// -----------------------------
|
||||
// fetch procedures
|
||||
// -----------------------------
|
||||
const { data: procedures = [], isLoading } = useQuery<AppointmentProcedure[]>(
|
||||
{
|
||||
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,
|
||||
},
|
||||
);
|
||||
|
||||
// -----------------------------
|
||||
// mutations
|
||||
// -----------------------------
|
||||
const addManualMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const payload = {
|
||||
appointmentId,
|
||||
patientId,
|
||||
procedureCode: manualCode,
|
||||
procedureLabel: manualLabel || null,
|
||||
fee: manualFee ? Number(manualFee) : null,
|
||||
toothNumber: manualTooth || null,
|
||||
toothSurface: manualSurface || null,
|
||||
source: "MANUAL",
|
||||
};
|
||||
|
||||
const res = await apiRequest(
|
||||
"POST",
|
||||
"/api/appointment-procedures",
|
||||
payload,
|
||||
);
|
||||
if (!res.ok) throw new Error("Failed to add procedure");
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({ title: "Procedure added" });
|
||||
setManualCode("");
|
||||
setManualLabel("");
|
||||
setManualFee("");
|
||||
setManualTooth("");
|
||||
setManualSurface("");
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["appointment-procedures", appointmentId],
|
||||
});
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: err.message ?? "Failed to add procedure",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const bulkAddMutation = useMutation({
|
||||
mutationFn: async (rows: any[]) => {
|
||||
const res = await apiRequest(
|
||||
"POST",
|
||||
"/api/appointment-procedures/bulk",
|
||||
rows,
|
||||
);
|
||||
if (!res.ok) throw new Error("Failed to add combo procedures");
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({ title: "Combo added" });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["appointment-procedures", appointmentId],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
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: any) => {
|
||||
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: string) => {
|
||||
const combo = PROCEDURE_COMBOS[comboKey];
|
||||
if (!combo || !patient?.dateOfBirth) return;
|
||||
|
||||
const serviceDate = new Date();
|
||||
const dob = patient.dateOfBirth;
|
||||
|
||||
const age = (() => {
|
||||
const birth = new Date(dob);
|
||||
const ref = new Date(serviceDate);
|
||||
let a = ref.getFullYear() - birth.getFullYear();
|
||||
const hadBirthday =
|
||||
ref.getMonth() > birth.getMonth() ||
|
||||
(ref.getMonth() === birth.getMonth() &&
|
||||
ref.getDate() >= birth.getDate());
|
||||
if (!hadBirthday) a -= 1;
|
||||
return a;
|
||||
})();
|
||||
|
||||
const rows = combo.codes.map((code: string, idx: number) => {
|
||||
const priceDecimal = getPriceForCodeWithAgeFromMap(CODE_MAP, code, age);
|
||||
|
||||
return {
|
||||
appointmentId,
|
||||
patientId,
|
||||
procedureCode: code,
|
||||
procedureLabel: combo.label,
|
||||
fee: priceDecimal.toNumber(),
|
||||
source: "COMBO",
|
||||
comboKey: comboKey,
|
||||
toothNumber: combo.toothNumbers?.[idx] ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
bulkAddMutation.mutate(rows);
|
||||
};
|
||||
|
||||
const startEdit = (row: AppointmentProcedure) => {
|
||||
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 handleDirectClaim = () => {
|
||||
setLocation(`/claims?appointmentId=${appointmentId}&mode=direct`);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleManualClaim = () => {
|
||||
setLocation(`/claims?appointmentId=${appointmentId}&mode=manual`);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
// -----------------------------
|
||||
// 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(); // block only when delete dialog is open
|
||||
}
|
||||
}}
|
||||
onInteractOutside={(e) => {
|
||||
if (clearAllOpen) {
|
||||
e.preventDefault(); // block only when delete dialog is open
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl font-semibold">
|
||||
Appointment Procedures
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* ================= COMBOS ================= */}
|
||||
<div className="space-y-8 pointer-events-auto">
|
||||
<DirectComboButtons
|
||||
onDirectCombo={(comboKey) => {
|
||||
handleAddCombo(comboKey);
|
||||
}}
|
||||
/>
|
||||
|
||||
<RegularComboButtons
|
||||
onRegularCombo={(comboKey) => {
|
||||
handleAddCombo(comboKey);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ================= MANUAL ADD ================= */}
|
||||
<div className="mt-8 border rounded-lg p-4 bg-muted/20 space-y-3">
|
||||
<div className="font-medium text-sm">Add Manual Procedure</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-3">
|
||||
<div>
|
||||
<Label>Code</Label>
|
||||
<Input
|
||||
value={manualCode}
|
||||
onChange={(e) => setManualCode(e.target.value)}
|
||||
placeholder="D0120"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Label</Label>
|
||||
<Input
|
||||
value={manualLabel}
|
||||
onChange={(e) => setManualLabel(e.target.value)}
|
||||
placeholder="Exam"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Fee</Label>
|
||||
<Input
|
||||
value={manualFee}
|
||||
onChange={(e) => setManualFee(e.target.value)}
|
||||
placeholder="100"
|
||||
type="number"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Tooth</Label>
|
||||
<Input
|
||||
value={manualTooth}
|
||||
onChange={(e) => setManualTooth(e.target.value)}
|
||||
placeholder="14"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Surface</Label>
|
||||
<Input
|
||||
value={manualSurface}
|
||||
onChange={(e) => setManualSurface(e.target.value)}
|
||||
placeholder="MO"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => addManualMutation.mutate()}
|
||||
disabled={!manualCode || addManualMutation.isPending}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add Procedure
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ================= LIST ================= */}
|
||||
<div className="mt-8 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-semibold">Selected Procedures</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">
|
||||
{/* ===== TABLE HEADER ===== */}
|
||||
<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
|
||||
</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={() => 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();
|
||||
}}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
54
apps/Frontend/src/components/appointments/add-appointment-modal.tsx
Executable file
54
apps/Frontend/src/components/appointments/add-appointment-modal.tsx
Executable file
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { AppointmentForm } from "./appointment-form";
|
||||
import {
|
||||
Appointment,
|
||||
InsertAppointment,
|
||||
UpdateAppointment,
|
||||
} from "@repo/db/types";
|
||||
|
||||
interface AddAppointmentModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (data: InsertAppointment | UpdateAppointment) => void;
|
||||
onDelete?: (id: number) => void;
|
||||
isLoading: boolean;
|
||||
appointment?: Appointment;
|
||||
}
|
||||
|
||||
export function AddAppointmentModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
onDelete,
|
||||
isLoading,
|
||||
appointment,
|
||||
}: AddAppointmentModalProps) {
|
||||
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}
|
||||
onSubmit={(data) => {
|
||||
onSubmit(data);
|
||||
onOpenChange(false);
|
||||
}}
|
||||
isLoading={isLoading}
|
||||
onDelete={onDelete}
|
||||
onOpenChange={onOpenChange}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
763
apps/Frontend/src/components/appointments/appointment-form.tsx
Executable file
763
apps/Frontend/src/components/appointments/appointment-form.tsx
Executable file
@@ -0,0 +1,763 @@
|
||||
import { useEffect, useMemo, 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 { 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 {
|
||||
Appointment,
|
||||
InsertAppointment,
|
||||
insertAppointmentSchema,
|
||||
Patient,
|
||||
Staff,
|
||||
UpdateAppointment,
|
||||
} from "@repo/db/types";
|
||||
import { DateInputField } from "@/components/ui/dateInputField";
|
||||
import { formatLocalDate, parseLocalDate } from "@/utils/dateUtils";
|
||||
import {
|
||||
PatientSearch,
|
||||
SearchCriteria,
|
||||
} from "@/components/patients/patient-search";
|
||||
import { toast } from "@/hooks/use-toast";
|
||||
|
||||
interface AppointmentFormProps {
|
||||
appointment?: Appointment;
|
||||
onSubmit: (data: InsertAppointment | UpdateAppointment) => void;
|
||||
onDelete?: (id: number) => void;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function AppointmentForm({
|
||||
appointment,
|
||||
onSubmit,
|
||||
onDelete,
|
||||
onOpenChange,
|
||||
isLoading = false,
|
||||
}: AppointmentFormProps) {
|
||||
const { user } = useAuth();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [prefillPatient, setPrefillPatient] = useState<Patient | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 50); // small delay ensures content is mounted
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, []);
|
||||
|
||||
const { data: staffMembersRaw = [] as Staff[] } = useQuery<Staff[]>({
|
||||
queryKey: ["/api/staffs/"],
|
||||
queryFn: async () => {
|
||||
const res = await apiRequest("GET", "/api/staffs/");
|
||||
return res.json();
|
||||
},
|
||||
enabled: !!user,
|
||||
});
|
||||
|
||||
const colorMap: Record<string, string> = {
|
||||
"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",
|
||||
}));
|
||||
|
||||
// Get the stored data from session storage
|
||||
const storedDataString = sessionStorage.getItem("newAppointmentData");
|
||||
let parsedStoredData = null;
|
||||
|
||||
// Try to parse it if it exists
|
||||
if (storedDataString) {
|
||||
try {
|
||||
parsedStoredData = JSON.parse(storedDataString);
|
||||
} catch (error) {
|
||||
console.error("Error parsing stored appointment data:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Format the date and times for the form
|
||||
const defaultValues: Partial<Appointment> = appointment
|
||||
? {
|
||||
userId: user?.id,
|
||||
patientId: appointment.patientId,
|
||||
title: appointment.title,
|
||||
date: parseLocalDate(appointment.date),
|
||||
startTime: appointment.startTime || "09:00", // Default "09:00"
|
||||
endTime: appointment.endTime || "09:30", // Default "09:30"
|
||||
type: appointment.type,
|
||||
notes: appointment.notes || "",
|
||||
status: appointment.status || "scheduled",
|
||||
staffId:
|
||||
typeof appointment.staffId === "number"
|
||||
? appointment.staffId
|
||||
: undefined,
|
||||
}
|
||||
: parsedStoredData
|
||||
? {
|
||||
userId: user?.id,
|
||||
patientId: Number(parsedStoredData.patientId),
|
||||
date: parsedStoredData.date
|
||||
? parseLocalDate(parsedStoredData.date)
|
||||
: parseLocalDate(new Date()),
|
||||
title: parsedStoredData.title || "",
|
||||
startTime: parsedStoredData.startTime,
|
||||
endTime: parsedStoredData.endTime,
|
||||
type: parsedStoredData.type || "checkup",
|
||||
status: parsedStoredData.status || "scheduled",
|
||||
notes: parsedStoredData.notes || "",
|
||||
staffId:
|
||||
typeof parsedStoredData.staff === "number"
|
||||
? parsedStoredData.staff
|
||||
: (staffMembers?.[0]?.id ?? undefined),
|
||||
}
|
||||
: {
|
||||
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<InsertAppointment>({
|
||||
resolver: zodResolver(insertAppointmentSchema),
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
// -----------------------------
|
||||
// PATIENT SEARCH (reuse PatientSearch)
|
||||
// -----------------------------
|
||||
const [selectOpen, setSelectOpen] = useState(false);
|
||||
|
||||
// search criteria state (reused from patient page)
|
||||
const [searchCriteria, setSearchCriteria] = useState<SearchCriteria | null>(
|
||||
null
|
||||
);
|
||||
const [isSearchActive, setIsSearchActive] = useState(false);
|
||||
|
||||
// debounce search criteria so we don't hammer the backend
|
||||
const [debouncedSearchCriteria] = useDebounce(searchCriteria, 300);
|
||||
|
||||
const limit = 50; // dropdown size
|
||||
const offset = 0; // always first page for dropdown
|
||||
|
||||
// compute key used in patient page: recent or trimmed term
|
||||
const searchKeyPart = useMemo(
|
||||
() => debouncedSearchCriteria?.searchTerm?.trim() || "recent",
|
||||
[debouncedSearchCriteria]
|
||||
);
|
||||
|
||||
// Query function mirrors PatientTable logic (so backend contract is identical)
|
||||
const queryFn = async (): Promise<Patient[]> => {
|
||||
const trimmedTerm = debouncedSearchCriteria?.searchTerm?.trim();
|
||||
const isSearch = !!trimmedTerm && trimmedTerm.length > 0;
|
||||
const rawSearchBy = debouncedSearchCriteria?.searchBy || "name";
|
||||
const validSearchKeys = [
|
||||
"name",
|
||||
"phone",
|
||||
"insuranceId",
|
||||
"gender",
|
||||
"dob",
|
||||
"all",
|
||||
];
|
||||
const searchKey = validSearchKeys.includes(rawSearchBy)
|
||||
? rawSearchBy
|
||||
: "name";
|
||||
|
||||
let url: string;
|
||||
if (isSearch) {
|
||||
const searchParams = new URLSearchParams({
|
||||
limit: String(limit),
|
||||
offset: String(offset),
|
||||
});
|
||||
|
||||
if (searchKey === "all") {
|
||||
searchParams.set("term", trimmedTerm!);
|
||||
} else {
|
||||
searchParams.set(searchKey, trimmedTerm!);
|
||||
}
|
||||
|
||||
url = `/api/patients/search?${searchParams.toString()}`;
|
||||
} else {
|
||||
url = `/api/patients/recent?limit=${limit}&offset=${offset}`;
|
||||
}
|
||||
|
||||
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();
|
||||
// Expect payload to be { patients: Patient[], totalCount: number } or just an array.
|
||||
// Normalize: if payload.patients exists, return it; otherwise assume array of patients.
|
||||
return Array.isArray(payload) ? payload : (payload.patients ?? []);
|
||||
};
|
||||
|
||||
const {
|
||||
data: patients = [],
|
||||
isFetching: isFetchingPatients,
|
||||
refetch: refetchPatients,
|
||||
} = useQuery<Patient[], Error>({
|
||||
queryKey: ["patients-dropdown", searchKeyPart],
|
||||
queryFn,
|
||||
enabled: selectOpen || !!debouncedSearchCriteria?.searchTerm,
|
||||
});
|
||||
|
||||
// If select opened and no patients loaded, fetch
|
||||
useEffect(() => {
|
||||
if (selectOpen && (!patients || patients.length === 0)) {
|
||||
refetchPatients();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectOpen]);
|
||||
|
||||
// Force form field values to update and clean up storage
|
||||
useEffect(() => {
|
||||
if (!parsedStoredData) return;
|
||||
|
||||
// set times/staff/date as before
|
||||
if (parsedStoredData.startTime)
|
||||
form.setValue("startTime", parsedStoredData.startTime);
|
||||
if (parsedStoredData.endTime)
|
||||
form.setValue("endTime", parsedStoredData.endTime);
|
||||
if (parsedStoredData.staff)
|
||||
form.setValue("staffId", parsedStoredData.staff);
|
||||
if (parsedStoredData.date) {
|
||||
form.setValue("date", parseLocalDate(parsedStoredData.date));
|
||||
}
|
||||
|
||||
// ---- patient prefill: check main cache, else fetch once ----
|
||||
if (parsedStoredData.patientId) {
|
||||
const pid = Number(parsedStoredData.patientId);
|
||||
if (!Number.isNaN(pid)) {
|
||||
// ensure the form value is set
|
||||
form.setValue("patientId", pid);
|
||||
|
||||
// fetch single patient record (preferred)
|
||||
(async () => {
|
||||
try {
|
||||
const res = await apiRequest("GET", `/api/patients/${pid}`);
|
||||
if (res.ok) {
|
||||
const patientRecord = await res.json();
|
||||
setPrefillPatient(patientRecord);
|
||||
} else {
|
||||
// non-OK response: show toast with status / message
|
||||
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 as Error)?.message ||
|
||||
"An unknown error occurred while fetching patient details.",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
// remove the one-time transport
|
||||
sessionStorage.removeItem("newAppointmentData");
|
||||
}
|
||||
})();
|
||||
}
|
||||
} else {
|
||||
// no patientId in storage — still remove to avoid stale state
|
||||
sessionStorage.removeItem("newAppointmentData");
|
||||
}
|
||||
}, [form]);
|
||||
|
||||
// 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 as Error)?.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: InsertAppointment) => {
|
||||
// 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");
|
||||
}
|
||||
|
||||
let 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; // Handle this case as well
|
||||
}
|
||||
|
||||
// If there's no staff information in the notes, add it
|
||||
if (!notes.includes("Appointment with")) {
|
||||
notes = notes
|
||||
? `${notes}\nAppointment with ${selectedStaff?.name}`
|
||||
: `Appointment with ${selectedStaff?.name}`;
|
||||
}
|
||||
|
||||
const formattedDate = formatLocalDate(data.date);
|
||||
|
||||
onSubmit({
|
||||
...data,
|
||||
userId: Number(user?.id),
|
||||
title,
|
||||
notes,
|
||||
patientId,
|
||||
date: formattedDate,
|
||||
startTime: data.startTime,
|
||||
endTime: data.endTime,
|
||||
});
|
||||
};
|
||||
|
||||
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: boolean) => {
|
||||
setSelectOpen(open);
|
||||
if (!open) {
|
||||
// reset transient search state when the dropdown closes
|
||||
setSearchCriteria(null);
|
||||
setIsSearchActive(false);
|
||||
|
||||
// Remove transient prefill if the main cached list contains it now
|
||||
if (
|
||||
prefillPatient &&
|
||||
patients &&
|
||||
patients.some(
|
||||
(p) => Number(p.id) === Number(prefillPatient.id)
|
||||
)
|
||||
) {
|
||||
setPrefillPatient(null);
|
||||
}
|
||||
} else {
|
||||
// when opened, ensure initial results
|
||||
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>
|
||||
{/* Reuse full PatientSearch UI inside dropdown — callbacks update the query */}
|
||||
<div className="p-2" onKeyDown={(e) => e.stopPropagation()}>
|
||||
<PatientSearch
|
||||
onSearch={(criteria) => {
|
||||
setSearchCriteria(criteria);
|
||||
setIsSearchActive(true);
|
||||
}}
|
||||
onClearSearch={() => {
|
||||
setSearchCriteria({
|
||||
searchTerm: "",
|
||||
searchBy: "name",
|
||||
});
|
||||
setIsSearchActive(false);
|
||||
}}
|
||||
isSearchActive={isSearchActive}
|
||||
/>
|
||||
</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={field.onChange}
|
||||
value={field.value}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a type" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="checkup">Checkup</SelectItem>
|
||||
<SelectItem value="cleaning">Cleaning</SelectItem>
|
||||
<SelectItem value="filling">Filling</SelectItem>
|
||||
<SelectItem value="extraction">Extraction</SelectItem>
|
||||
<SelectItem value="root-canal">Root Canal</SelectItem>
|
||||
<SelectItem value="crown">Crown</SelectItem>
|
||||
<SelectItem value="dentures">Dentures</SelectItem>
|
||||
<SelectItem value="consultation">Consultation</SelectItem>
|
||||
<SelectItem value="emergency">Emergency</SelectItem>
|
||||
<SelectItem value="other">Other</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
55
apps/Frontend/src/components/appointments/patient-status-badge.tsx
Executable file
55
apps/Frontend/src/components/appointments/patient-status-badge.tsx
Executable file
@@ -0,0 +1,55 @@
|
||||
import * as React from "react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { PatientStatus } from "@repo/db/types";
|
||||
|
||||
export function PatientStatusBadge({
|
||||
status,
|
||||
className = "",
|
||||
size = 10,
|
||||
}: {
|
||||
status: PatientStatus;
|
||||
className?: string;
|
||||
size?: number; // px
|
||||
}) {
|
||||
const { bg, label } = getVisuals(status);
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
aria-label={`Patient status: ${label}`}
|
||||
className={`inline-block rounded-full ring-2 ring-white 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: PatientStatus): { label: string; bg: string } {
|
||||
switch (status) {
|
||||
case "ACTIVE":
|
||||
return { label: "Active", bg: "#16A34A" }; // MEDICAL GREEN (not same as staff green)
|
||||
case "INACTIVE":
|
||||
return { label: "Inactive", bg: "#DC2626" }; // ALERT RED (distinct from card red)
|
||||
default:
|
||||
return { label: "Unknown", bg: "#6B7280" }; // solid gray
|
||||
}
|
||||
}
|
||||
148
apps/Frontend/src/components/claims/claim-document-upload-modal.tsx
Executable file
148
apps/Frontend/src/components/claims/claim-document-upload-modal.tsx
Executable file
@@ -0,0 +1,148 @@
|
||||
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,
|
||||
MultipleFileUploadZoneHandle,
|
||||
} 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<MultipleFileUploadZoneHandle | null>(null);
|
||||
const [filesForUI, setFilesForUI] = useState<File[]>([]);
|
||||
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: File[]) => {
|
||||
setFilesForUI(files);
|
||||
}, []);
|
||||
|
||||
// Dummy save (simulate async). Replace with real API call when needed.
|
||||
const handleSave = useCallback(async (files: File[]) => {
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
313
apps/Frontend/src/components/claims/claim-edit-modal.tsx
Executable file
313
apps/Frontend/src/components/claims/claim-edit-modal.tsx
Executable file
@@ -0,0 +1,313 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { formatDateToHumanReadable } from "@/utils/dateUtils";
|
||||
import React, { useState } from "react";
|
||||
import { ClaimStatus, ClaimWithServiceLines } from "@repo/db/types";
|
||||
import {
|
||||
safeParseMissingTeeth,
|
||||
splitTeeth,
|
||||
ToothChip,
|
||||
toStatusLabel,
|
||||
} from "./tooth-ui";
|
||||
|
||||
type ClaimEditModalProps = {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onClose: () => void;
|
||||
claim: ClaimWithServiceLines | null;
|
||||
onSave: (updatedClaim: ClaimWithServiceLines) => void;
|
||||
};
|
||||
|
||||
export default function ClaimEditModal({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
onClose,
|
||||
claim,
|
||||
onSave,
|
||||
}: ClaimEditModalProps) {
|
||||
const [status, setStatus] = useState<ClaimStatus>(
|
||||
claim?.status ?? ("PENDING" as ClaimStatus)
|
||||
);
|
||||
|
||||
if (!claim) return null;
|
||||
|
||||
const handleSave = () => {
|
||||
const updatedClaim: ClaimWithServiceLines = {
|
||||
...claim,
|
||||
status,
|
||||
};
|
||||
|
||||
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: {claim.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 as ClaimStatus)}
|
||||
>
|
||||
<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>
|
||||
</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>
|
||||
|
||||
{/* 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 as any)
|
||||
: 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 as any).missingTeethStatus)}
|
||||
</p>
|
||||
|
||||
{/* Only show details when the user chose "Specify Missing" */}
|
||||
{(claim as any).missingTeethStatus === "Yes_missing" &&
|
||||
(() => {
|
||||
const map = safeParseMissingTeeth((claim as any).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 as any).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>
|
||||
);
|
||||
}
|
||||
1504
apps/Frontend/src/components/claims/claim-form.tsx
Executable file
1504
apps/Frontend/src/components/claims/claim-form.tsx
Executable file
File diff suppressed because it is too large
Load Diff
369
apps/Frontend/src/components/claims/claim-view-modal.tsx
Executable file
369
apps/Frontend/src/components/claims/claim-view-modal.tsx
Executable file
@@ -0,0 +1,369 @@
|
||||
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 { ClaimFileMeta, ClaimWithServiceLines } from "@repo/db/types";
|
||||
import { FileText, Paperclip } from "lucide-react";
|
||||
import {
|
||||
safeParseMissingTeeth,
|
||||
splitTeeth,
|
||||
ToothChip,
|
||||
toStatusLabel,
|
||||
} from "./tooth-ui";
|
||||
|
||||
type ClaimViewModalProps = {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onClose: () => void;
|
||||
claim: ClaimWithServiceLines | null;
|
||||
onEditClaim: (claim: ClaimWithServiceLines) => void;
|
||||
};
|
||||
|
||||
export default function ClaimViewModal({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
onClose,
|
||||
claim,
|
||||
onEditClaim,
|
||||
}: ClaimViewModalProps) {
|
||||
// Normalizer: supports both ClaimFile[] and nested-create shape { create: ClaimFile[] }
|
||||
const getClaimFilesArray = (
|
||||
c: ClaimWithServiceLines | null
|
||||
): ClaimFileMeta[] => {
|
||||
if (!c) return [];
|
||||
|
||||
// If it's already a plain array (runtime from Prisma include), return it
|
||||
const maybeFiles = (c as any).claimFiles;
|
||||
if (!maybeFiles) return [];
|
||||
|
||||
if (Array.isArray(maybeFiles)) {
|
||||
// ensure each item has filename field (best-effort)
|
||||
return maybeFiles.map((f: any) => ({
|
||||
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: any) => ({
|
||||
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 as any).missingTeethStatus)}
|
||||
</p>
|
||||
|
||||
{/* Only show details when the user chose "Specify Missing" */}
|
||||
{(claim as any).missingTeethStatus === "Yes_missing" &&
|
||||
(() => {
|
||||
const map = safeParseMissingTeeth(
|
||||
(claim as any).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 as any).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>
|
||||
);
|
||||
}
|
||||
83
apps/Frontend/src/components/claims/claims-of-patient-table.tsx
Executable file
83
apps/Frontend/src/components/claims/claims-of-patient-table.tsx
Executable file
@@ -0,0 +1,83 @@
|
||||
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";
|
||||
import { Patient } from "@repo/db/types";
|
||||
|
||||
interface ClaimsOfPatientModalProps {
|
||||
onNewClaim?: (patientId: number) => void;
|
||||
}
|
||||
|
||||
export default function ClaimsOfPatientModal({
|
||||
onNewClaim,
|
||||
}: ClaimsOfPatientModalProps) {
|
||||
const [selectedPatient, setSelectedPatient] = useState<Patient | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [claimsPage, setClaimsPage] = useState(1);
|
||||
|
||||
const handleSelectPatient = (patient: Patient | null) => {
|
||||
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>
|
||||
);
|
||||
}
|
||||
626
apps/Frontend/src/components/claims/claims-recent-table.tsx
Executable file
626
apps/Frontend/src/components/claims/claims-recent-table.tsx
Executable file
@@ -0,0 +1,626 @@
|
||||
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,
|
||||
} from "lucide-react";
|
||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { useAppDispatch } from "@/redux/hooks";
|
||||
import { setTaskStatus } from "@/redux/slices/seleniumClaimSubmitTaskSlice";
|
||||
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 { Claim, ClaimStatus, ClaimWithServiceLines } from "@repo/db/types";
|
||||
import { getPageNumbers } from "@/utils/pageNumberGenerator";
|
||||
|
||||
interface ClaimApiResponse {
|
||||
claims: ClaimWithServiceLines[];
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
interface ClaimsRecentTableProps {
|
||||
allowEdit?: boolean;
|
||||
allowView?: boolean;
|
||||
allowDelete?: boolean;
|
||||
allowCheckbox?: boolean;
|
||||
onSelectClaim?: (claim: Claim | null) => void;
|
||||
onPageChange?: (page: number) => void;
|
||||
patientId?: number;
|
||||
}
|
||||
|
||||
// 🔑 exported base key
|
||||
export const QK_CLAIMS_BASE = ["claims-recent"] as const;
|
||||
// helper for specific pages/patient scope
|
||||
export const qkClaimsRecent = (opts: {
|
||||
patientId?: number | null;
|
||||
page: number;
|
||||
}) =>
|
||||
opts.patientId
|
||||
? ([...QK_CLAIMS_BASE, "patient", opts.patientId, opts.page] as const)
|
||||
: ([...QK_CLAIMS_BASE, "global", opts.page] as const);
|
||||
|
||||
export default function ClaimsRecentTable({
|
||||
allowEdit,
|
||||
allowView,
|
||||
allowDelete,
|
||||
allowCheckbox,
|
||||
onSelectClaim,
|
||||
onPageChange,
|
||||
patientId,
|
||||
}: ClaimsRecentTableProps) {
|
||||
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<
|
||||
ClaimWithServiceLines | undefined
|
||||
>(undefined);
|
||||
const [selectedClaimId, setSelectedClaimId] = useState<number | null>(null);
|
||||
|
||||
const handleSelectClaim = (claim: 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<ClaimApiResponse, Error>({
|
||||
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: ClaimWithServiceLines) => {
|
||||
const response = await apiRequest("PUT", `/api/claims/${claim.id}`, {
|
||||
status: claim.status,
|
||||
});
|
||||
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: number) => {
|
||||
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: ClaimWithServiceLines) => {
|
||||
setCurrentClaim(claim);
|
||||
setIsEditClaimOpen(true);
|
||||
};
|
||||
|
||||
const handleViewClaim = (claim: ClaimWithServiceLines) => {
|
||||
setCurrentClaim(claim);
|
||||
setIsViewClaimOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteClaim = (claim: ClaimWithServiceLines) => {
|
||||
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: string) => {
|
||||
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: number) => {
|
||||
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?: ClaimStatus) => {
|
||||
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: ClaimWithServiceLines) => {
|
||||
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>Patient Name</TableHead>
|
||||
<TableHead>Submission Date</TableHead>
|
||||
<TableHead>Insurance Provider</TableHead>
|
||||
<TableHead>Member ID</TableHead>
|
||||
<TableHead>Total Billed</TableHead>
|
||||
<TableHead>Status</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="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">
|
||||
{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 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({
|
||||
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({
|
||||
status: "success",
|
||||
message: "Claims automation completed. Browser remains open.",
|
||||
})
|
||||
);
|
||||
} else {
|
||||
handleViewClaim(claim);
|
||||
}
|
||||
} catch {
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
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 as number);
|
||||
}}
|
||||
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>
|
||||
);
|
||||
}
|
||||
83
apps/Frontend/src/components/claims/claims-ui.tsx
Executable file
83
apps/Frontend/src/components/claims/claims-ui.tsx
Executable file
@@ -0,0 +1,83 @@
|
||||
import React from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
export function RemarksField({
|
||||
value,
|
||||
onChange,
|
||||
debounceMs = 250, // tweak (150–300) if you like
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (next: string) => void;
|
||||
debounceMs?: number;
|
||||
}) {
|
||||
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<number | null>(null);
|
||||
const schedulePush = React.useCallback(
|
||||
(next: string) => {
|
||||
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>
|
||||
);
|
||||
}
|
||||
191
apps/Frontend/src/components/claims/tooth-ui.tsx
Executable file
191
apps/Frontend/src/components/claims/tooth-ui.tsx
Executable file
@@ -0,0 +1,191 @@
|
||||
import React from "react";
|
||||
import { Label } from "recharts";
|
||||
import { Input } from "../ui/input";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
// ——— Missing Teeth helpers for claim-view and edit modal———
|
||||
type MissingMap = Record<string, ToothVal | undefined>;
|
||||
|
||||
export function toStatusLabel(s?: string) {
|
||||
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: unknown): MissingMap {
|
||||
if (!raw) return {};
|
||||
if (typeof raw === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === "object") return parsed as MissingMap;
|
||||
} catch {}
|
||||
return {};
|
||||
}
|
||||
if (typeof raw === "object") return raw as MissingMap;
|
||||
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: MissingMap) {
|
||||
const permanent: Array<{ name: string; v: ToothVal }> = [];
|
||||
const primary: Array<{ name: string; v: ToothVal }> = [];
|
||||
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 }: { name: string; v: ToothVal }) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
export type ToothVal = "X" | "O";
|
||||
export type MissingMapStrict = Record<string, ToothVal>;
|
||||
|
||||
/* ---------- 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: string): string | null {
|
||||
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: string, val: ToothVal): Array<[string, ToothVal]> {
|
||||
if (!list) return [];
|
||||
const seen = new Set<string>();
|
||||
return list
|
||||
.split(/[,\s]+/g) // commas OR spaces
|
||||
.map(normalizeToothToken) // uppercase + validate
|
||||
.filter((t): t is string => !!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: string,
|
||||
pullList: string
|
||||
): MissingMapStrict {
|
||||
const map: MissingMapStrict = {};
|
||||
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: Record<string, ToothVal | undefined>): {
|
||||
missing: string;
|
||||
toPull: string;
|
||||
} {
|
||||
const missing: string[] = [];
|
||||
const toPull: string[] = [];
|
||||
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: string, b: string) => {
|
||||
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,
|
||||
}: {
|
||||
/** Must match ClaimFormData.missingTeeth exactly */
|
||||
value: MissingMapStrict;
|
||||
onChange: (next: MissingMapStrict) => void;
|
||||
}) {
|
||||
// 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: string, pStr: string) => {
|
||||
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>
|
||||
);
|
||||
}
|
||||
203
apps/Frontend/src/components/cloud-storage/bread-crumb.tsx
Executable file
203
apps/Frontend/src/components/cloud-storage/bread-crumb.tsx
Executable file
@@ -0,0 +1,203 @@
|
||||
import { Fragment, useEffect, useRef, useState } from "react";
|
||||
|
||||
/**
|
||||
* Improved Breadcrumbs helper component
|
||||
* - Renders a pill-style path with chevrons
|
||||
* - Collapses middle items when path is long and exposes them via an ellipsis dropdown
|
||||
* - Clickable items, accessible, responsive truncation
|
||||
*/
|
||||
|
||||
export type FolderMeta = {
|
||||
id: number | null;
|
||||
name: string | null;
|
||||
parentId: number | null;
|
||||
};
|
||||
|
||||
export function Breadcrumbs({
|
||||
path,
|
||||
onNavigate,
|
||||
}: {
|
||||
path: FolderMeta[];
|
||||
onNavigate: (id: number | null) => void;
|
||||
}) {
|
||||
const [openEllipsis, setOpenEllipsis] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// close dropdown on outside click
|
||||
useEffect(() => {
|
||||
function onDocClick(e: MouseEvent) {
|
||||
if (!dropdownRef.current) return;
|
||||
if (!dropdownRef.current.contains(e.target as Node)) {
|
||||
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,
|
||||
}: {
|
||||
id: number | null;
|
||||
name: string | null;
|
||||
active?: boolean;
|
||||
}) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
284
apps/Frontend/src/components/cloud-storage/file-preview-modal.tsx
Executable file
284
apps/Frontend/src/components/cloud-storage/file-preview-modal.tsx
Executable file
@@ -0,0 +1,284 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||
import { toast } from "@/hooks/use-toast";
|
||||
import { Download, Maximize2, Minimize2, Trash2, X } from "lucide-react";
|
||||
import { DeleteConfirmationDialog } from "../ui/deleteDialog";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { cloudFilesQueryKeyRoot } from "./files-section";
|
||||
|
||||
type Props = {
|
||||
fileId: number | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onDeleted?: () => void;
|
||||
};
|
||||
|
||||
export default function FilePreviewModal({
|
||||
fileId,
|
||||
isOpen,
|
||||
onClose,
|
||||
onDeleted,
|
||||
}: Props) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [meta, setMeta] = useState<any | null>(null);
|
||||
const [blobUrl, setBlobUrl] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(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: string | null = 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: any) {
|
||||
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: any) {
|
||||
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: any) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
584
apps/Frontend/src/components/cloud-storage/files-section.tsx
Executable file
584
apps/Frontend/src/components/cloud-storage/files-section.tsx
Executable file
@@ -0,0 +1,584 @@
|
||||
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 type { CloudFile } from "@repo/db/types";
|
||||
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";
|
||||
|
||||
export type FilesSectionProps = {
|
||||
parentId: number | null;
|
||||
pageSize?: number;
|
||||
className?: string;
|
||||
onFileOpen?: (fileId: number) => void;
|
||||
};
|
||||
|
||||
// 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: number | null,
|
||||
page: number,
|
||||
pageSize: number
|
||||
) => [
|
||||
"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?: string) {
|
||||
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" />;
|
||||
}
|
||||
|
||||
export default function FilesSection({
|
||||
parentId,
|
||||
pageSize = FILES_LIMIT_DEFAULT,
|
||||
className,
|
||||
onFileOpen,
|
||||
}: FilesSectionProps) {
|
||||
const qc = useQueryClient();
|
||||
const { user } = useAuth();
|
||||
const userId = user?.id;
|
||||
|
||||
const [data, setData] = useState<CloudFile[]>([]);
|
||||
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<any>(null);
|
||||
|
||||
// rename/delete
|
||||
const [isRenameOpen, setIsRenameOpen] = useState(false);
|
||||
const [renameTargetId, setRenameTargetId] = useState<number | null>(null);
|
||||
const [renameInitial, setRenameInitial] = useState("");
|
||||
|
||||
// delete dialog
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<CloudFile | null>(null);
|
||||
|
||||
// preview modal
|
||||
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
|
||||
const [previewFileId, setPreviewFileId] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadPage(currentPage);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [parentId, currentPage]);
|
||||
|
||||
async function loadPage(page: number) {
|
||||
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: CloudFile[] = 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: any) {
|
||||
setData([]);
|
||||
setTotal(0);
|
||||
toast({
|
||||
title: "Failed to load files",
|
||||
description: err?.message ?? String(err),
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function showMenu(e: React.MouseEvent, file: CloudFile) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
contextMenu.show({
|
||||
id: "files-section-menu",
|
||||
event: e.nativeEvent,
|
||||
props: { file },
|
||||
});
|
||||
}
|
||||
|
||||
// rename
|
||||
function openRename(file: CloudFile) {
|
||||
setRenameTargetId(Number(file.id));
|
||||
setRenameInitial(file.name ?? "");
|
||||
setIsRenameOpen(true);
|
||||
contextMenu.hideAll();
|
||||
}
|
||||
async function submitRename(newName: string) {
|
||||
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: any) {
|
||||
toast({
|
||||
title: "Rename failed",
|
||||
description: err?.message ?? String(err),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// delete
|
||||
function openDelete(file: CloudFile) {
|
||||
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: any) {
|
||||
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: CloudFile) {
|
||||
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: any) {
|
||||
toast({
|
||||
title: "Download failed",
|
||||
description: err?.message ?? String(err),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// upload: get files from MultipleFileUploadZone (imperative handle)
|
||||
async function handleUploadSubmit() {
|
||||
const files: File[] = 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: any) {
|
||||
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: CloudFile) {
|
||||
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="p-3 rounded border hover:bg-gray-50 cursor-pointer"
|
||||
onContextMenu={(e) => showMenu(e, file)}
|
||||
onClick={() => openPreview(file)}
|
||||
>
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="h-10 w-10 text-gray-500 mb-2 flex items-center justify-center">
|
||||
{fileIcon((file as any).mimeType)}
|
||||
</div>
|
||||
<div
|
||||
className="text-sm truncate text-center"
|
||||
style={{ maxWidth: 140 }}
|
||||
>
|
||||
<div title={file.name}>{file.name}</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
{((file as any).fileSize ?? 0).toString()} bytes
|
||||
</div>
|
||||
</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: any) => {
|
||||
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: any) => {
|
||||
e.preventDefault();
|
||||
setCurrentPage(page as number);
|
||||
}}
|
||||
isActive={currentPage === page}
|
||||
>
|
||||
{page}
|
||||
</PaginationLink>
|
||||
)}
|
||||
</PaginationItem>
|
||||
)
|
||||
)}
|
||||
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
href="#"
|
||||
onClick={(e: any) => {
|
||||
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 }: any) => openRename(props.file)}>
|
||||
<span className="flex items-center gap-2">
|
||||
<EditIcon className="h-4 w-4" /> Rename
|
||||
</span>
|
||||
</Item>
|
||||
|
||||
<Item onClick={({ props }: any) => handleDownload(props.file)}>
|
||||
<span className="flex items-center gap-2">
|
||||
<Download className="h-4 w-4" /> Download
|
||||
</span>
|
||||
</Item>
|
||||
|
||||
<Item onClick={({ props }: any) => 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>
|
||||
);
|
||||
}
|
||||
152
apps/Frontend/src/components/cloud-storage/folder-panel.tsx
Executable file
152
apps/Frontend/src/components/cloud-storage/folder-panel.tsx
Executable file
@@ -0,0 +1,152 @@
|
||||
import React, { useEffect, useRef, 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, FolderMeta } from "./bread-crumb";
|
||||
|
||||
type Props = {
|
||||
folderId: number | null;
|
||||
onClose?: () => void;
|
||||
onViewChange?: (id: number | null) => void;
|
||||
};
|
||||
|
||||
export default function FolderPanel({
|
||||
folderId,
|
||||
onClose,
|
||||
onViewChange,
|
||||
}: Props) {
|
||||
const [currentFolderId, setCurrentFolderId] = useState<number | null>(
|
||||
folderId ?? null
|
||||
);
|
||||
const [path, setPath] = useState<FolderMeta[]>([]);
|
||||
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: number | null) {
|
||||
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: FolderMeta[] = [];
|
||||
let cursor: number | null = 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: FolderMeta = {
|
||||
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: number | null) {
|
||||
// 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: number | null) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
400
apps/Frontend/src/components/cloud-storage/folder-section.tsx
Executable file
400
apps/Frontend/src/components/cloud-storage/folder-section.tsx
Executable file
@@ -0,0 +1,400 @@
|
||||
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 type { CloudFolder } from "@repo/db/types";
|
||||
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 type FolderSectionProps = {
|
||||
parentId: number | null;
|
||||
pageSize?: number;
|
||||
className?: string;
|
||||
onSelect?: (folderId: number | null) => void;
|
||||
};
|
||||
|
||||
export default function FolderSection({
|
||||
parentId,
|
||||
pageSize = 10,
|
||||
className,
|
||||
onSelect,
|
||||
}: FolderSectionProps) {
|
||||
const qc = useQueryClient();
|
||||
const { user } = useAuth();
|
||||
const userId = user?.id;
|
||||
|
||||
const [data, setData] = useState<CloudFolder[]>([]);
|
||||
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<number | null>(null);
|
||||
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<CloudFolder | null>(null);
|
||||
|
||||
const [selectedId, setSelectedId] = useState<number | null>(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: number) {
|
||||
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: CloudFolder[] = 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: any) {
|
||||
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: number) {
|
||||
const next = selectedId === id ? null : id;
|
||||
setSelectedId(next);
|
||||
onSelect?.(next);
|
||||
contextMenu.hideAll();
|
||||
}
|
||||
|
||||
// right-click menu via react-contexify
|
||||
function showMenu(e: React.MouseEvent, folder: CloudFolder) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
contextMenu.show({
|
||||
id: `folder-section-menu`,
|
||||
event: e.nativeEvent,
|
||||
props: { folder },
|
||||
});
|
||||
}
|
||||
|
||||
// create folder
|
||||
async function handleCreate(name: string) {
|
||||
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: any) {
|
||||
toast({
|
||||
title: "Create failed",
|
||||
description: err?.message ?? String(err),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// rename
|
||||
function openRename(folder: CloudFolder) {
|
||||
setRenameTargetId(Number(folder.id));
|
||||
setRenameInitial(folder.name ?? "");
|
||||
setIsRenameOpen(true);
|
||||
contextMenu.hideAll();
|
||||
}
|
||||
async function submitRename(newName: string) {
|
||||
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: any) {
|
||||
toast({
|
||||
title: "Rename failed",
|
||||
description: err?.message ?? String(err),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// delete
|
||||
function openDelete(folder: CloudFolder) {
|
||||
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: any) {
|
||||
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: any) => {
|
||||
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: any) => {
|
||||
e.preventDefault();
|
||||
setCurrentPage(page as number);
|
||||
}}
|
||||
isActive={currentPage === page}
|
||||
>
|
||||
{page}
|
||||
</PaginationLink>
|
||||
)}
|
||||
</PaginationItem>
|
||||
)
|
||||
)}
|
||||
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
href="#"
|
||||
onClick={(e: any) => {
|
||||
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 }: any) => openRename(props.folder)}>
|
||||
<span className="flex items-center gap-2">
|
||||
<EditIcon className="h-4 w-4" /> Rename
|
||||
</span>
|
||||
</Item>
|
||||
|
||||
<Item onClick={({ props }: any) => 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>
|
||||
);
|
||||
}
|
||||
100
apps/Frontend/src/components/cloud-storage/new-folder-modal.tsx
Executable file
100
apps/Frontend/src/components/cloud-storage/new-folder-modal.tsx
Executable file
@@ -0,0 +1,100 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Folder, Search as SearchIcon } from "lucide-react";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import type { CloudFolder } from "@repo/db/types";
|
||||
import FolderPanel from "@/components/cloud-storage/folder-panel";
|
||||
|
||||
// -----------------------------
|
||||
// Reusable NewFolderModal
|
||||
// -----------------------------
|
||||
export type NewFolderModalProps = {
|
||||
isOpen: boolean;
|
||||
initialName?: string;
|
||||
title?: string;
|
||||
submitLabel?: string;
|
||||
onClose: () => void;
|
||||
onSubmit: (name: string) => Promise<void> | void;
|
||||
};
|
||||
|
||||
export function NewFolderModal({
|
||||
isOpen,
|
||||
initialName = "",
|
||||
title = "New Folder",
|
||||
submitLabel = "Create",
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: NewFolderModalProps) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
353
apps/Frontend/src/components/cloud-storage/recent-top-level-folder-modal.tsx
Executable file
353
apps/Frontend/src/components/cloud-storage/recent-top-level-folder-modal.tsx
Executable file
@@ -0,0 +1,353 @@
|
||||
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 type { CloudFolder } from "@repo/db/types";
|
||||
import { getPageNumbers } from "@/utils/pageNumberGenerator";
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationItem,
|
||||
PaginationPrevious,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
} from "@/components/ui/pagination";
|
||||
import type { QueryKey } from "@tanstack/react-query";
|
||||
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: number): QueryKey => [
|
||||
"/api/cloud-storage/folders/recent",
|
||||
page,
|
||||
];
|
||||
|
||||
export type RecentTopLevelFoldersCardProps = {
|
||||
pageSize?: number;
|
||||
initialPage?: number;
|
||||
className?: string;
|
||||
onSelect?: (folderId: number | null) => void;
|
||||
};
|
||||
|
||||
export default function RecentTopLevelFoldersCard({
|
||||
pageSize = 10,
|
||||
initialPage = 1,
|
||||
className,
|
||||
onSelect,
|
||||
}: RecentTopLevelFoldersCardProps) {
|
||||
const [currentPage, setCurrentPage] = useState<number>(initialPage);
|
||||
const [selectedFolderId, setSelectedFolderId] = useState<number | null>(null);
|
||||
|
||||
const [isRenameOpen, setIsRenameOpen] = useState(false);
|
||||
const [renameInitialName, setRenameInitialName] = useState<string>("");
|
||||
const [renameTargetId, setRenameTargetId] = useState<number | null>(null);
|
||||
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<CloudFolder | null>(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: CloudFolder[] = 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: number) {
|
||||
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: React.MouseEvent, folder: CloudFolder) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
contextMenu.show({
|
||||
id: "recent-folder-context-menu",
|
||||
event: e.nativeEvent,
|
||||
props: { folder },
|
||||
});
|
||||
}
|
||||
|
||||
// rename flow
|
||||
function openRename(folder: CloudFolder) {
|
||||
setRenameTargetId(Number(folder.id));
|
||||
setRenameInitialName(folder.name ?? "");
|
||||
setIsRenameOpen(true);
|
||||
contextMenu.hideAll();
|
||||
}
|
||||
|
||||
async function handleRenameSubmit(newName: string) {
|
||||
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: any) {
|
||||
toast({ title: "Error", description: err?.message || String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
// delete flow
|
||||
function openDelete(folder: CloudFolder) {
|
||||
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: any) {
|
||||
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: any) => {
|
||||
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: any) => {
|
||||
e.preventDefault();
|
||||
setCurrentPage(page as number);
|
||||
}}
|
||||
isActive={currentPage === page}
|
||||
>
|
||||
{page}
|
||||
</PaginationLink>
|
||||
)}
|
||||
</PaginationItem>
|
||||
)
|
||||
)}
|
||||
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
href="#"
|
||||
onClick={(e: any) => {
|
||||
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 }: any) => {
|
||||
const folder: CloudFolder | undefined = 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 }: any) => {
|
||||
const folder: CloudFolder | undefined = 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>
|
||||
);
|
||||
}
|
||||
426
apps/Frontend/src/components/cloud-storage/search-bar.tsx
Executable file
426
apps/Frontend/src/components/cloud-storage/search-bar.tsx
Executable file
@@ -0,0 +1,426 @@
|
||||
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: string,
|
||||
searchTarget: "filename" | "foldername" | "both",
|
||||
typeFilter: "any" | "images" | "pdf" | "video" | "audio",
|
||||
page: number
|
||||
) => ["cloud-search", q, searchTarget, typeFilter, page];
|
||||
|
||||
type ResultRow = {
|
||||
id: number;
|
||||
name: string;
|
||||
mimeType?: string | null;
|
||||
folderId?: number | null;
|
||||
isComplete?: boolean;
|
||||
kind: "file" | "folder";
|
||||
fileSize?: string | number | null;
|
||||
createdAt?: string;
|
||||
};
|
||||
|
||||
export default function CloudSearchBar({
|
||||
onOpenFolder = (id: number | null) => {},
|
||||
onSelectFile = (fileId: number) => {},
|
||||
}: {
|
||||
onOpenFolder?: (id: number | null) => void;
|
||||
onSelectFile?: (fileId: number) => void;
|
||||
}) {
|
||||
const [q, setQ] = useState("");
|
||||
const [searchTarget, setSearchTarget] = useState<
|
||||
"filename" | "foldername" | "both"
|
||||
>("filename"); // default filename
|
||||
const [typeFilter, setTypeFilter] = useState<
|
||||
"any" | "images" | "pdf" | "video" | "audio"
|
||||
>("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: string) {
|
||||
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(): Promise<{
|
||||
results: ResultRow[];
|
||||
total: number;
|
||||
}> {
|
||||
const query = debouncedQ ?? "";
|
||||
if (!query) return { results: [], total: 0 };
|
||||
|
||||
const offset = (page - 1) * limit;
|
||||
const typeParam = typeParamFromFilter(typeFilter as string);
|
||||
|
||||
// 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: ResultRow[] = (json.data || []).map((d: any) => ({
|
||||
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: ResultRow[] = (json.data || []).map((d: any) => ({
|
||||
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 as any)?.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: string[] = 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: Record<string, ResultRow[]> = 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<string[]>(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem("cloud_search_recent_terms");
|
||||
return raw ? JSON.parse(raw) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
const [recentMatches, setRecentMatches] = useState<
|
||||
Record<string, ResultRow[]>
|
||||
>(() => {
|
||||
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 as any)}
|
||||
value={searchTarget}
|
||||
>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue placeholder="Search target" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="filename">Filename (default)</SelectItem>
|
||||
<SelectItem value="foldername">
|
||||
Folder name (top-level)
|
||||
</SelectItem>
|
||||
<SelectItem value="both">Both</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
onValueChange={(v) => setTypeFilter(v as any)}
|
||||
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>
|
||||
);
|
||||
}
|
||||
165
apps/Frontend/src/components/database-management/backup-destination-manager.tsx
Executable file
165
apps/Frontend/src/components/database-management/backup-destination-manager.tsx
Executable file
@@ -0,0 +1,165 @@
|
||||
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 {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { FolderOpen, Trash2 } from "lucide-react";
|
||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
export function BackupDestinationManager() {
|
||||
const { toast } = useToast();
|
||||
const [path, setPath] = useState("");
|
||||
const [deleteId, setDeleteId] = useState<number | null>(null);
|
||||
|
||||
// ==============================
|
||||
// Queries
|
||||
// ==============================
|
||||
const { data: destinations = [] } = useQuery({
|
||||
queryKey: ["/db/destination"],
|
||||
queryFn: async () => {
|
||||
const res = await apiRequest(
|
||||
"GET",
|
||||
"/api/database-management/destination"
|
||||
);
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
|
||||
// ==============================
|
||||
// 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: number) => {
|
||||
await apiRequest("DELETE", `/api/database-management/destination/${id}`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({ title: "Backup destination deleted" });
|
||||
queryClient.invalidateQueries({ queryKey: ["/db/destination"] });
|
||||
setDeleteId(null);
|
||||
},
|
||||
});
|
||||
|
||||
// ==============================
|
||||
// Folder picker (browser limitation)
|
||||
// ==============================
|
||||
const openFolderPicker = async () => {
|
||||
// @ts-ignore
|
||||
if (!window.showDirectoryPicker) {
|
||||
toast({
|
||||
title: "Not supported",
|
||||
description: "Your browser does not support folder picking",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// @ts-ignore
|
||||
const dirHandle = await window.showDirectoryPicker();
|
||||
|
||||
toast({
|
||||
title: "Folder selected",
|
||||
description: `Selected folder: ${dirHandle.name}. Please enter the full path manually.`,
|
||||
});
|
||||
} catch {
|
||||
// user cancelled
|
||||
}
|
||||
};
|
||||
|
||||
// ==============================
|
||||
// UI
|
||||
// ==============================
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>External Backup Destination</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<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={openFolderPicker}>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => saveMutation.mutate()}
|
||||
disabled={!path || saveMutation.isPending}
|
||||
>
|
||||
Save Destination
|
||||
</Button>
|
||||
|
||||
<div className="space-y-2">
|
||||
{destinations.map((d: any) => (
|
||||
<div
|
||||
key={d.id}
|
||||
className="flex justify-between items-center border rounded p-2"
|
||||
>
|
||||
<span className="text-sm text-gray-700">{d.path}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => setDeleteId(d.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
283
apps/Frontend/src/components/documents/file-preview-modal.tsx
Executable file
283
apps/Frontend/src/components/documents/file-preview-modal.tsx
Executable file
@@ -0,0 +1,283 @@
|
||||
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";
|
||||
|
||||
type Props = {
|
||||
fileId: number | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
initialFileName?: string | null;
|
||||
isPatientDocument?: boolean;
|
||||
directImageUrl?: string; // Add prop for direct image URL
|
||||
};
|
||||
|
||||
export default function DocumentsFilePreviewModal({
|
||||
fileId,
|
||||
isOpen,
|
||||
onClose,
|
||||
initialFileName,
|
||||
isPatientDocument = false,
|
||||
directImageUrl,
|
||||
}: Props) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [mime, setMime] = useState<string | null>(null);
|
||||
const [fileName, setFileName] = useState<string | null>(
|
||||
initialFileName ?? null
|
||||
);
|
||||
const [blobUrl, setBlobUrl] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !fileId) return;
|
||||
let cancelled = false;
|
||||
let createdUrl: string | null = null;
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setMime(null);
|
||||
setFileName(initialFileName ?? null);
|
||||
setBlobUrl(null);
|
||||
|
||||
try {
|
||||
let res: Response;
|
||||
|
||||
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: any) {
|
||||
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: KeyboardEvent) {
|
||||
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: string;
|
||||
|
||||
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: any) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
276
apps/Frontend/src/components/file-upload/file-upload-zone.tsx
Executable file
276
apps/Frontend/src/components/file-upload/file-upload-zone.tsx
Executable file
@@ -0,0 +1,276 @@
|
||||
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";
|
||||
|
||||
interface FileUploadZoneProps {
|
||||
onFileUpload: (file: File) => void;
|
||||
isUploading: boolean;
|
||||
acceptedFileTypes?: string;
|
||||
// OPTIONAL: fallback max file size MB
|
||||
maxFileSizeMB?: number;
|
||||
// OPTIONAL: per-type size map in MB, e.g. { "application/pdf": 10, "image/*": 2 }
|
||||
maxFileSizeByType?: Record<string, number>;
|
||||
}
|
||||
|
||||
export function FileUploadZone({
|
||||
onFileUpload,
|
||||
isUploading,
|
||||
acceptedFileTypes = "application/pdf",
|
||||
maxFileSizeMB = 10, // default 10mb
|
||||
maxFileSizeByType,
|
||||
}: FileUploadZoneProps) {
|
||||
const { toast } = useToast();
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// helpers
|
||||
const mbToBytes = (mb: number) => Math.round(mb * 1024 * 1024);
|
||||
const humanSize = (bytes: number) => {
|
||||
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: string | undefined) => {
|
||||
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: string | undefined) => {
|
||||
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: 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: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(true);
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback(
|
||||
(e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!isDragging) {
|
||||
setIsDragging(true);
|
||||
}
|
||||
},
|
||||
[isDragging]
|
||||
);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent<HTMLDivElement>) => {
|
||||
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: React.ChangeEvent<HTMLInputElement>) => {
|
||||
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>
|
||||
);
|
||||
}
|
||||
460
apps/Frontend/src/components/file-upload/multiple-file-upload-zone.tsx
Executable file
460
apps/Frontend/src/components/file-upload/multiple-file-upload-zone.tsx
Executable file
@@ -0,0 +1,460 @@
|
||||
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 type MultipleFileUploadZoneHandle = {
|
||||
getFiles: () => File[];
|
||||
reset: () => void;
|
||||
removeFile: (index: number) => void;
|
||||
};
|
||||
|
||||
interface FileUploadZoneProps {
|
||||
onFilesChange?: (files: File[]) => void;
|
||||
isUploading?: boolean;
|
||||
acceptedFileTypes?: string;
|
||||
maxFiles?: number;
|
||||
//OPTIONAL: default max per-file (MB) when no per-type rule matches
|
||||
maxFileSizeMB?: number;
|
||||
//OPTIONAL: per-mime (or wildcard) map in MB: { "application/pdf": 10, "image/*": 2 }
|
||||
maxFileSizeByType?: Record<string, number>;
|
||||
}
|
||||
|
||||
export const MultipleFileUploadZone = forwardRef<
|
||||
MultipleFileUploadZoneHandle,
|
||||
FileUploadZoneProps
|
||||
>(
|
||||
(
|
||||
{
|
||||
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<File[]>([]);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const parsedAccept = acceptedFileTypes
|
||||
.split(",")
|
||||
.map((s) => s.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
|
||||
// helper: convert MB -> bytes
|
||||
const mbToBytes = (mb: number) => Math.round(mb * 1024 * 1024);
|
||||
|
||||
// human readable size
|
||||
const humanSize = (bytes: number) => {
|
||||
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: string | undefined) => {
|
||||
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: string) => {
|
||||
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: 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: string) => {
|
||||
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<string>();
|
||||
|
||||
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: File[]) => {
|
||||
onFilesChange?.(files);
|
||||
},
|
||||
[onFilesChange]
|
||||
);
|
||||
|
||||
const handleFiles = (files: FileList | null) => {
|
||||
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: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(true);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleDragLeave = useCallback(
|
||||
(e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleDragOver = useCallback(
|
||||
(e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!isDragging) {
|
||||
setIsDragging(true);
|
||||
}
|
||||
},
|
||||
[isDragging]
|
||||
);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
handleFiles(e.dataTransfer.files);
|
||||
},
|
||||
[uploadedFiles]
|
||||
);
|
||||
|
||||
const handleFileSelect = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
handleFiles(e.target.files);
|
||||
},
|
||||
[uploadedFiles]
|
||||
);
|
||||
|
||||
const handleBrowseClick = () => {
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.click();
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveFile = (index: number) => {
|
||||
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: number) => {
|
||||
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";
|
||||
566
apps/Frontend/src/components/insurance-status/ddma-buton-modal.tsx
Executable file
566
apps/Frontend/src/components/insurance-status/ddma-buton-modal.tsx
Executable file
@@ -0,0 +1,566 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { io as ioClient, Socket } from "socket.io-client";
|
||||
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/seleniumEligibilityCheckTaskSlice";
|
||||
import { formatLocalDate } from "@/utils/dateUtils";
|
||||
import { QK_PATIENTS_BASE } from "@/components/patients/patient-table";
|
||||
|
||||
const SOCKET_URL =
|
||||
import.meta.env.VITE_API_BASE_URL_BACKEND ||
|
||||
(typeof window !== "undefined" ? window.location.origin : "");
|
||||
|
||||
// ---------- OTP Modal component ----------
|
||||
interface DdmaOtpModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (otp: string) => Promise<void> | void;
|
||||
isSubmitting: boolean;
|
||||
}
|
||||
|
||||
function DdmaOtpModal({
|
||||
open,
|
||||
onClose,
|
||||
onSubmit,
|
||||
isSubmitting,
|
||||
}: DdmaOtpModalProps) {
|
||||
const [otp, setOtp] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) setOtp("");
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Main DDMA Eligibility button component ----------
|
||||
interface DdmaEligibilityButtonProps {
|
||||
memberId: string;
|
||||
dateOfBirth: Date | null;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
isFormIncomplete: boolean;
|
||||
/** Called when backend has finished and PDF is ready */
|
||||
onPdfReady: (pdfId: number, fallbackFilename: string | null) => void;
|
||||
}
|
||||
|
||||
export function DdmaEligibilityButton({
|
||||
memberId,
|
||||
dateOfBirth,
|
||||
firstName,
|
||||
lastName,
|
||||
isFormIncomplete,
|
||||
onPdfReady,
|
||||
}: DdmaEligibilityButtonProps) {
|
||||
const { toast } = useToast();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const socketRef = useRef<Socket | null>(null);
|
||||
const connectingRef = useRef<Promise<void> | null>(null);
|
||||
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
const [otpModalOpen, setOtpModalOpen] = useState(false);
|
||||
const [isStarting, setIsStarting] = useState(false);
|
||||
const [isSubmittingOtp, setIsSubmittingOtp] = useState(false);
|
||||
|
||||
// Clean up socket on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (socketRef.current) {
|
||||
socketRef.current.removeAllListeners();
|
||||
socketRef.current.disconnect();
|
||||
socketRef.current = null;
|
||||
}
|
||||
connectingRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const closeSocket = () => {
|
||||
try {
|
||||
socketRef.current?.removeAllListeners();
|
||||
socketRef.current?.disconnect();
|
||||
} catch (e) {
|
||||
// ignore
|
||||
} finally {
|
||||
socketRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
// Lazy socket setup: called only when we actually need it (first click)
|
||||
const ensureSocketConnected = async () => {
|
||||
// If already connected, nothing to do
|
||||
if (socketRef.current && socketRef.current.connected) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If a connection is in progress, reuse that promise
|
||||
if (connectingRef.current) {
|
||||
return connectingRef.current;
|
||||
}
|
||||
|
||||
const promise = new Promise<void>((resolve, reject) => {
|
||||
const socket = ioClient(SOCKET_URL, {
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
socketRef.current = socket;
|
||||
|
||||
socket.on("connect", () => {
|
||||
console.log("DDMA socket connected:", socket.id);
|
||||
resolve();
|
||||
});
|
||||
|
||||
// connection error when first connecting (or later)
|
||||
socket.on("connect_error", (err: any) => {
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
status: "error",
|
||||
message: "Connection failed",
|
||||
})
|
||||
);
|
||||
toast({
|
||||
title: "Realtime connection failed",
|
||||
description:
|
||||
"Could not connect to realtime server. Retrying automatically...",
|
||||
variant: "destructive",
|
||||
});
|
||||
// do not reject here because socket.io will attempt reconnection
|
||||
});
|
||||
|
||||
// socket.io will emit 'reconnect_attempt' for retries
|
||||
socket.on("reconnect_attempt", (attempt: number) => {
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
status: "pending",
|
||||
message: `Realtime reconnect attempt #${attempt}`,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// when reconnection failed after configured attempts
|
||||
socket.on("reconnect_failed", () => {
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
status: "error",
|
||||
message: "Reconnect failed",
|
||||
})
|
||||
);
|
||||
toast({
|
||||
title: "Realtime reconnect failed",
|
||||
description:
|
||||
"Connection to realtime server could not be re-established. Please try again later.",
|
||||
variant: "destructive",
|
||||
});
|
||||
// terminal failure — cleanup and reject so caller can stop start flow
|
||||
closeSocket();
|
||||
reject(new Error("Realtime reconnect failed"));
|
||||
});
|
||||
|
||||
socket.on("disconnect", (reason: any) => {
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
status: "error",
|
||||
message: "Connection disconnected",
|
||||
})
|
||||
);
|
||||
toast({
|
||||
title: "Connection Disconnected",
|
||||
description:
|
||||
"Connection to the server was lost. If a DDMA job was running it may have failed.",
|
||||
variant: "destructive",
|
||||
});
|
||||
// clear sessionId/OTP modal
|
||||
setSessionId(null);
|
||||
setOtpModalOpen(false);
|
||||
});
|
||||
|
||||
// OTP required
|
||||
socket.on("selenium:otp_required", (payload: any) => {
|
||||
if (!payload?.session_id) return;
|
||||
setSessionId(payload.session_id);
|
||||
setOtpModalOpen(true);
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
status: "pending",
|
||||
message: "OTP required for DDMA eligibility. Please enter the OTP.",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// OTP submitted (optional UX)
|
||||
socket.on("selenium:otp_submitted", (payload: any) => {
|
||||
if (!payload?.session_id) return;
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
status: "pending",
|
||||
message: "OTP submitted. Finishing DDMA eligibility check...",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Session update
|
||||
socket.on("selenium:session_update", (payload: any) => {
|
||||
const { session_id, status, final } = payload || {};
|
||||
if (!session_id) return;
|
||||
|
||||
if (status === "completed") {
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
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.",
|
||||
variant: "default",
|
||||
});
|
||||
|
||||
const pdfId = final?.pdfFileId;
|
||||
if (pdfId) {
|
||||
const filename =
|
||||
final?.pdfFilename ?? `eligibility_ddma_${memberId}.pdf`;
|
||||
onPdfReady(Number(pdfId), filename);
|
||||
}
|
||||
|
||||
setSessionId(null);
|
||||
setOtpModalOpen(false);
|
||||
} else if (status === "error") {
|
||||
const msg =
|
||||
payload?.message ||
|
||||
final?.error ||
|
||||
"DDMA eligibility session failed.";
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
status: "error",
|
||||
message: msg,
|
||||
})
|
||||
);
|
||||
toast({
|
||||
title: "DDMA selenium error",
|
||||
description: msg,
|
||||
variant: "destructive",
|
||||
});
|
||||
|
||||
// Ensure socket is torn down for this session (stop receiving stale events)
|
||||
try {
|
||||
closeSocket();
|
||||
} catch (e) {}
|
||||
setSessionId(null);
|
||||
setOtpModalOpen(false);
|
||||
}
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: QK_PATIENTS_BASE });
|
||||
});
|
||||
|
||||
// explicit session error event (helpful)
|
||||
socket.on("selenium:session_error", (payload: any) => {
|
||||
const msg = payload?.message || "Selenium session error";
|
||||
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
status: "error",
|
||||
message: msg,
|
||||
})
|
||||
);
|
||||
|
||||
toast({
|
||||
title: "Selenium session error",
|
||||
description: msg,
|
||||
variant: "destructive",
|
||||
});
|
||||
|
||||
// tear down socket to avoid stale updates
|
||||
try {
|
||||
closeSocket();
|
||||
} catch (e) {}
|
||||
setSessionId(null);
|
||||
setOtpModalOpen(false);
|
||||
});
|
||||
|
||||
// If socket.io initial connection fails permanently (very rare: client-level)
|
||||
// set a longer timeout to reject the first attempt to connect.
|
||||
const initialConnectTimeout = setTimeout(() => {
|
||||
if (!socket.connected) {
|
||||
// if still not connected after 8s, treat as failure and reject so caller can handle it
|
||||
closeSocket();
|
||||
reject(new Error("Realtime initial connection timeout"));
|
||||
}
|
||||
}, 8000);
|
||||
|
||||
// When the connect resolves we should clear this timer
|
||||
socket.once("connect", () => {
|
||||
clearTimeout(initialConnectTimeout);
|
||||
});
|
||||
});
|
||||
|
||||
// store promise to prevent multiple concurrent connections
|
||||
connectingRef.current = promise;
|
||||
|
||||
try {
|
||||
await promise;
|
||||
} finally {
|
||||
connectingRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const startDdmaEligibility = async () => {
|
||||
if (!memberId || !dateOfBirth) {
|
||||
toast({
|
||||
title: "Missing fields",
|
||||
description: "Member ID and Date of Birth are required.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const formattedDob = dateOfBirth ? formatLocalDate(dateOfBirth) : "";
|
||||
|
||||
const payload = {
|
||||
memberId,
|
||||
dateOfBirth: formattedDob,
|
||||
firstName,
|
||||
lastName,
|
||||
insuranceSiteKey: "DDMA", // make sure this matches backend credential key
|
||||
};
|
||||
|
||||
try {
|
||||
setIsStarting(true);
|
||||
|
||||
// 1) Ensure socket is connected (lazy)
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
status: "pending",
|
||||
message: "Opening realtime channel for DDMA eligibility...",
|
||||
})
|
||||
);
|
||||
await ensureSocketConnected();
|
||||
|
||||
const socket = socketRef.current;
|
||||
if (!socket || !socket.connected) {
|
||||
throw new Error("Socket connection failed");
|
||||
}
|
||||
|
||||
const socketId = socket.id;
|
||||
|
||||
// 2) Start the selenium job via backend
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
status: "pending",
|
||||
message: "Starting DDMA eligibility check via selenium...",
|
||||
})
|
||||
);
|
||||
|
||||
const response = await apiRequest(
|
||||
"POST",
|
||||
"/api/insurance-status-ddma/ddma-eligibility",
|
||||
{
|
||||
data: JSON.stringify(payload),
|
||||
socketId,
|
||||
}
|
||||
);
|
||||
|
||||
// If apiRequest threw, we would have caught above; but just in case it returns.
|
||||
let result: any = null;
|
||||
let backendError: string | null = null;
|
||||
|
||||
try {
|
||||
// attempt JSON first
|
||||
result = await response.clone().json();
|
||||
backendError =
|
||||
result?.error || result?.message || result?.detail || null;
|
||||
} catch {
|
||||
// fallback to text response
|
||||
try {
|
||||
const text = await response.clone().text();
|
||||
backendError = text?.trim() || null;
|
||||
} catch {
|
||||
backendError = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
backendError ||
|
||||
`DDMA selenium start failed (status ${response.status})`
|
||||
);
|
||||
}
|
||||
|
||||
// Normal success path: optional: if backend returns non-error shape still check for result.error
|
||||
if (result?.error) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
|
||||
if (result.status === "started" && result.session_id) {
|
||||
setSessionId(result.session_id as string);
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
status: "pending",
|
||||
message:
|
||||
"DDMA eligibility job started. Waiting for OTP or final result...",
|
||||
})
|
||||
);
|
||||
} else {
|
||||
// fallback if backend returns immediate result
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
status: "success",
|
||||
message: "DDMA eligibility completed.",
|
||||
})
|
||||
);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("startDdmaEligibility error:", err);
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
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",
|
||||
});
|
||||
} finally {
|
||||
setIsStarting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmitOtp = async (otp: string) => {
|
||||
if (!sessionId || !socketRef.current || !socketRef.current.connected) {
|
||||
toast({
|
||||
title: "Session not ready",
|
||||
description:
|
||||
"Could not submit OTP because the DDMA session or socket is not ready.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSubmittingOtp(true);
|
||||
const resp = await apiRequest(
|
||||
"POST",
|
||||
"/api/insurance-status-ddma/selenium/submit-otp",
|
||||
{
|
||||
session_id: sessionId,
|
||||
otp,
|
||||
socketId: socketRef.current.id,
|
||||
}
|
||||
);
|
||||
const data = await resp.json();
|
||||
if (!resp.ok || data.error) {
|
||||
throw new Error(data.error || "Failed to submit OTP");
|
||||
}
|
||||
|
||||
// from here we rely on websocket events (otp_submitted + session_update)
|
||||
setOtpModalOpen(false);
|
||||
} catch (err: any) {
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="default"
|
||||
disabled={isFormIncomplete || isStarting}
|
||||
onClick={startDdmaEligibility}
|
||||
>
|
||||
{isStarting ? (
|
||||
<>
|
||||
<LoaderCircleIcon className="h-4 w-4 mr-2 animate-spin" />
|
||||
Processing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
Delta MA Eligibility
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<DdmaOtpModal
|
||||
open={otpModalOpen}
|
||||
onClose={() => setOtpModalOpen(false)}
|
||||
onSubmit={handleSubmitOtp}
|
||||
isSubmitting={isSubmittingOtp}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
193
apps/Frontend/src/components/insurance-status/pdf-preview-modal.tsx
Executable file
193
apps/Frontend/src/components/insurance-status/pdf-preview-modal.tsx
Executable file
@@ -0,0 +1,193 @@
|
||||
// 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";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
pdfId?: number | null;
|
||||
fallbackFilename?: string | null;
|
||||
}
|
||||
|
||||
function parseFilenameFromContentDisposition(header: string | null): string | null {
|
||||
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,
|
||||
}: Props) {
|
||||
const [fileBlobUrl, setFileBlobUrl] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [resolvedFilename, setResolvedFilename] = useState<string | null>(null);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
let objectUrl: string | null = 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 blob = new Blob([arrayBuffer], { type: "application/pdf" });
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
setFileBlobUrl(objectUrl);
|
||||
} catch (err: any) {
|
||||
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);
|
||||
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 && (
|
||||
<iframe
|
||||
title="PDF Preview"
|
||||
src={fileBlobUrl}
|
||||
className="w-full h-full border"
|
||||
style={{ minHeight: 0 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
122
apps/Frontend/src/components/insurance/credentials-modal.tsx
Executable file
122
apps/Frontend/src/components/insurance/credentials-modal.tsx
Executable file
@@ -0,0 +1,122 @@
|
||||
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";
|
||||
|
||||
interface CredentialsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (credentials: { username: string; password: string }) => void;
|
||||
providerName: string;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function CredentialsModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSubmit,
|
||||
providerName,
|
||||
isLoading = false,
|
||||
}: CredentialsModalProps) {
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
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>
|
||||
);
|
||||
}
|
||||
25
apps/Frontend/src/components/layout/app-layout.tsx
Executable file
25
apps/Frontend/src/components/layout/app-layout.tsx
Executable file
@@ -0,0 +1,25 @@
|
||||
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 }: { children: React.ReactNode }) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
287
apps/Frontend/src/components/layout/notification-bell.tsx
Executable file
287
apps/Frontend/src/components/layout/notification-bell.tsx
Executable file
@@ -0,0 +1,287 @@
|
||||
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 { Notification } from "@repo/db/types";
|
||||
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 (): Promise<Notification[]> => {
|
||||
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: number) => {
|
||||
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<Notification[]>(["/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<Notification[]>(["/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: boolean) => {
|
||||
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>
|
||||
);
|
||||
}
|
||||
134
apps/Frontend/src/components/layout/sidebar.tsx
Executable file
134
apps/Frontend/src/components/layout/sidebar.tsx
Executable file
@@ -0,0 +1,134 @@
|
||||
import { Link, useLocation } from "wouter";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Users,
|
||||
Calendar,
|
||||
Settings,
|
||||
FileCheck,
|
||||
Shield,
|
||||
CreditCard,
|
||||
FolderOpen,
|
||||
Database,
|
||||
FileText,
|
||||
Cloud,
|
||||
Phone,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useMemo } from "react";
|
||||
import { useSidebar } from "@/components/ui/sidebar";
|
||||
|
||||
export function Sidebar() {
|
||||
const [location] = useLocation();
|
||||
const { state, openMobile, setOpenMobile } = useSidebar(); // "expanded" | "collapsed"
|
||||
|
||||
const navItems = useMemo(
|
||||
() => [
|
||||
{
|
||||
name: "Dashboard",
|
||||
path: "/dashboard",
|
||||
icon: <LayoutDashboard className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
name: "Patient Connection",
|
||||
path: "/patient-connection",
|
||||
icon: <Phone className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
name: "Appointments",
|
||||
path: "/appointments",
|
||||
icon: <Calendar className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
name: "Patients",
|
||||
path: "/patients",
|
||||
icon: <Users className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
name: "Eligibility/Claim Status",
|
||||
path: "/insurance-status",
|
||||
icon: <Shield className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
name: "Claims/PreAuth",
|
||||
path: "/claims",
|
||||
icon: <FileCheck className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
name: "Payments",
|
||||
path: "/payments",
|
||||
icon: <CreditCard className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
name: "Documents",
|
||||
path: "/documents",
|
||||
icon: <FolderOpen className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
name: "Reports",
|
||||
path: "/reports",
|
||||
icon: <FileText className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
name: "Cloud storage",
|
||||
path: "/cloud-storage",
|
||||
icon: <Cloud className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
name: "Backup Database",
|
||||
path: "/database-management",
|
||||
icon: <Database className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
name: "Settings",
|
||||
path: "/settings",
|
||||
icon: <Settings className="h-5 w-5" />,
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
// original look
|
||||
"bg-white border-r border-gray-200 shadow-sm z-20",
|
||||
// clip during width animation to avoid text peeking
|
||||
"overflow-hidden will-change-[width]",
|
||||
// animate width only
|
||||
"transition-[width] duration-200 ease-in-out",
|
||||
// MOBILE: overlay below topbar (h = 100vh - 4rem)
|
||||
openMobile
|
||||
? "fixed top-16 left-0 h-[calc(100vh-4rem)] w-64 block md:hidden"
|
||||
: "hidden md:block",
|
||||
// DESKTOP: participates in row layout
|
||||
"md:static md:top-auto md:h-auto md:flex-shrink-0",
|
||||
state === "collapsed" ? "md:w-0 overflow-hidden" : "md:w-64"
|
||||
)}
|
||||
>
|
||||
<div className="p-2">
|
||||
<nav role="navigation" aria-label="Main">
|
||||
{navItems.map((item) => (
|
||||
<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}
|
||||
{/* show label only after expand animation completes */}
|
||||
<span className="whitespace-nowrap select-none">
|
||||
{item.name}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
86
apps/Frontend/src/components/layout/top-app-bar.tsx
Executable file
86
apps/Frontend/src/components/layout/top-app-bar.tsx
Executable file
@@ -0,0 +1,86 @@
|
||||
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";
|
||||
|
||||
export function TopAppBar() {
|
||||
const { user, logoutMutation } = useAuth();
|
||||
const [location, setLocation] = useLocation();
|
||||
|
||||
const handleLogout = () => logoutMutation.mutate();
|
||||
const getInitials = (username: string) =>
|
||||
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 />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
251
apps/Frontend/src/components/patient-connection/message-thread.tsx
Executable file
251
apps/Frontend/src/components/patient-connection/message-thread.tsx
Executable file
@@ -0,0 +1,251 @@
|
||||
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 { useToast } from "@/hooks/use-toast";
|
||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||
import { Send, ArrowLeft } from "lucide-react";
|
||||
import type { Patient, Communication } from "@repo/db/types";
|
||||
import { format, isToday, isYesterday, parseISO } from "date-fns";
|
||||
|
||||
interface MessageThreadProps {
|
||||
patient: Patient;
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
export function MessageThread({ patient, onBack }: MessageThreadProps) {
|
||||
const { toast } = useToast();
|
||||
const [messageText, setMessageText] = useState("");
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { data: communications = [], isLoading } = useQuery<Communication[]>({
|
||||
queryKey: ["/api/patients", patient.id, "communications"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`/api/patients/${patient.id}/communications`, {
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to fetch communications");
|
||||
return res.json();
|
||||
},
|
||||
refetchInterval: 5000, // Refresh every 5 seconds to get new messages
|
||||
});
|
||||
|
||||
const sendMessageMutation = useMutation({
|
||||
mutationFn: async (message: string) => {
|
||||
return apiRequest("POST", "/api/twilio/send-sms", {
|
||||
to: patient.phone,
|
||||
message: message,
|
||||
patientId: patient.id,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
setMessageText("");
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["/api/patients", patient.id, "communications"],
|
||||
});
|
||||
toast({
|
||||
title: "Message sent",
|
||||
description: "Your message has been sent successfully.",
|
||||
});
|
||||
},
|
||||
onError: (error: any) => {
|
||||
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: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSendMessage();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [communications]);
|
||||
|
||||
const formatMessageDate = (dateValue: string | Date) => {
|
||||
const date =
|
||||
typeof dateValue === "string" ? parseISO(dateValue) : dateValue;
|
||||
if (isToday(date)) {
|
||||
return format(date, "h:mm a");
|
||||
} else if (isYesterday(date)) {
|
||||
return `Yesterday ${format(date, "h:mm a")}`;
|
||||
} else {
|
||||
return format(date, "MMM d, h:mm a");
|
||||
}
|
||||
};
|
||||
|
||||
const getDateDivider = (dateValue: string | Date) => {
|
||||
const messageDate =
|
||||
typeof dateValue === "string" ? parseISO(dateValue) : dateValue;
|
||||
if (isToday(messageDate)) {
|
||||
return "Today";
|
||||
} else if (isYesterday(messageDate)) {
|
||||
return "Yesterday";
|
||||
} else {
|
||||
return format(messageDate, "MMMM d, yyyy");
|
||||
}
|
||||
};
|
||||
|
||||
const groupedMessages: { date: string; messages: Communication[] }[] = [];
|
||||
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 existingGroup = groupedMessages.find((g) => g.date === dateKey);
|
||||
if (existingGroup) {
|
||||
existingGroup.messages.push(comm);
|
||||
} else {
|
||||
groupedMessages.push({ date: dateKey, messages: [comm] });
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-white rounded-lg border">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b bg-gray-50">
|
||||
<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="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-full bg-primary text-primary-foreground flex items-center justify-center font-semibold">
|
||||
{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>
|
||||
</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}>
|
||||
{/* Date Divider */}
|
||||
<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>
|
||||
|
||||
{/* Messages for this date */}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
226
apps/Frontend/src/components/patient-connection/sms-template-diaog.tsx
Executable file
226
apps/Frontend/src/components/patient-connection/sms-template-diaog.tsx
Executable file
@@ -0,0 +1,226 @@
|
||||
import { useState } from "react";
|
||||
import { 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 } from "lucide-react";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import type { Patient } from "@repo/db/types";
|
||||
|
||||
interface SmsTemplateDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
patient: Patient | null;
|
||||
}
|
||||
|
||||
const MESSAGE_TEMPLATES = {
|
||||
appointment_reminder: {
|
||||
name: "Appointment Reminder",
|
||||
template: (firstName: string) =>
|
||||
`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",
|
||||
template: (firstName: string) =>
|
||||
`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",
|
||||
template: (firstName: string) =>
|
||||
`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",
|
||||
template: (firstName: string) =>
|
||||
`Hi ${firstName}, this is a friendly reminder about your outstanding balance. Please contact our office to discuss payment options.`,
|
||||
},
|
||||
general: {
|
||||
name: "General Message",
|
||||
template: (firstName: string) =>
|
||||
`Hi ${firstName}, this is your dental office. `,
|
||||
},
|
||||
custom: {
|
||||
name: "Custom Message",
|
||||
template: () => "",
|
||||
},
|
||||
};
|
||||
|
||||
export function SmsTemplateDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
patient,
|
||||
}: SmsTemplateDialogProps) {
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<
|
||||
keyof typeof MESSAGE_TEMPLATES
|
||||
>("appointment_reminder");
|
||||
const [customMessage, setCustomMessage] = useState("");
|
||||
const { toast } = useToast();
|
||||
|
||||
const sendSmsMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
to,
|
||||
message,
|
||||
patientId,
|
||||
}: {
|
||||
to: string;
|
||||
message: string;
|
||||
patientId: number;
|
||||
}) => {
|
||||
return apiRequest("POST", "/api/twilio/send-sms", {
|
||||
to,
|
||||
message,
|
||||
patientId,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: "SMS Sent Successfully",
|
||||
description: `Message sent to ${patient?.firstName} ${patient?.lastName}`,
|
||||
});
|
||||
onOpenChange(false);
|
||||
// Reset state
|
||||
setSelectedTemplate("appointment_reminder");
|
||||
setCustomMessage("");
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: "Failed to Send SMS",
|
||||
description:
|
||||
error.message ||
|
||||
"Please check your Twilio configuration and try again.",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const getMessage = () => {
|
||||
if (!patient) return "";
|
||||
|
||||
if (selectedTemplate === "custom") {
|
||||
return customMessage;
|
||||
}
|
||||
|
||||
return MESSAGE_TEMPLATES[selectedTemplate].template(patient.firstName);
|
||||
};
|
||||
|
||||
const handleSend = () => {
|
||||
if (!patient || !patient.phone) return;
|
||||
|
||||
const message = getMessage();
|
||||
if (!message.trim()) return;
|
||||
|
||||
sendSmsMutation.mutate({
|
||||
to: patient.phone,
|
||||
message: message,
|
||||
patientId: Number(patient.id),
|
||||
});
|
||||
};
|
||||
|
||||
const handleTemplateChange = (value: string) => {
|
||||
const templateKey = value as keyof typeof MESSAGE_TEMPLATES;
|
||||
setSelectedTemplate(templateKey);
|
||||
|
||||
// Pre-fill custom message if not custom template
|
||||
if (templateKey !== "custom" && patient) {
|
||||
setCustomMessage(
|
||||
MESSAGE_TEMPLATES[templateKey].template(patient.firstName)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
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 message template or write a custom message
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="template">Message Template</Label>
|
||||
<Select
|
||||
value={selectedTemplate}
|
||||
onValueChange={handleTemplateChange}
|
||||
>
|
||||
<SelectTrigger id="template" data-testid="select-sms-template">
|
||||
<SelectValue placeholder="Select a template" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(MESSAGE_TEMPLATES).map(([key, value]) => (
|
||||
<SelectItem key={key} value={key}>
|
||||
{value.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="message">Message Preview</Label>
|
||||
<Textarea
|
||||
id="message"
|
||||
value={
|
||||
selectedTemplate === "custom" ? customMessage : getMessage()
|
||||
}
|
||||
onChange={(e) => setCustomMessage(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={handleSend}
|
||||
disabled={
|
||||
!patient?.phone ||
|
||||
!getMessage().trim() ||
|
||||
sendSmsMutation.isPending
|
||||
}
|
||||
className="gap-2"
|
||||
data-testid="button-send-sms"
|
||||
>
|
||||
{sendSmsMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
)}
|
||||
{sendSmsMutation.isPending ? "Sending..." : "Send SMS"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
184
apps/Frontend/src/components/patients/add-patient-modal.tsx
Executable file
184
apps/Frontend/src/components/patients/add-patient-modal.tsx
Executable file
@@ -0,0 +1,184 @@
|
||||
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, PatientFormRef } from "./patient-form";
|
||||
import { X, Calendar } from "lucide-react";
|
||||
import { useLocation } from "wouter";
|
||||
import { InsertPatient, Patient, UpdatePatient } from "@repo/db/types";
|
||||
|
||||
interface AddPatientModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (data: InsertPatient | (UpdatePatient & { id?: number })) => void;
|
||||
isLoading: boolean;
|
||||
patient?: Patient;
|
||||
extractedInfo?: {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
dateOfBirth: string;
|
||||
insuranceId: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Define the ref type
|
||||
export type AddPatientModalRef = {
|
||||
shouldSchedule: boolean;
|
||||
shouldClaim: boolean;
|
||||
navigateToSchedule: (patientId: number) => void;
|
||||
navigateToClaim: (patientId: number) => void;
|
||||
};
|
||||
|
||||
export const AddPatientModal = forwardRef<
|
||||
AddPatientModalRef,
|
||||
AddPatientModalProps
|
||||
>(function AddPatientModal(props, ref) {
|
||||
const { open, onOpenChange, onSubmit, isLoading, patient, extractedInfo } =
|
||||
props;
|
||||
const [formData, setFormData] = useState<
|
||||
InsertPatient | UpdatePatient | null
|
||||
>(null);
|
||||
const isEditing = !!patient;
|
||||
const [, navigate] = useLocation();
|
||||
const [saveAndSchedule, setSaveAndSchedule] = useState(false);
|
||||
const [saveAndClaim, setSaveAndClaim] = useState(false);
|
||||
const patientFormRef = useRef<PatientFormRef>(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: number) => {
|
||||
navigate(`/appointments?newPatient=${patientId}`);
|
||||
},
|
||||
navigateToClaim: (patientId: number) => {
|
||||
// ✅ NEW
|
||||
navigate(`/claims?newPatient=${patientId}`);
|
||||
},
|
||||
}));
|
||||
|
||||
const handleFormSubmit = (data: InsertPatient | UpdatePatient) => {
|
||||
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>
|
||||
);
|
||||
});
|
||||
366
apps/Frontend/src/components/patients/patient-financial-modal.tsx
Executable file
366
apps/Frontend/src/components/patients/patient-financial-modal.tsx
Executable file
@@ -0,0 +1,366 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
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 { FinancialRow } from "@repo/db/types";
|
||||
import { getPageNumbers } from "@/utils/pageNumberGenerator";
|
||||
|
||||
export function PatientFinancialsModal({
|
||||
patientId,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
patientId: number | null;
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
}) {
|
||||
const [rows, setRows] = useState<FinancialRow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [limit, setLimit] = useState<number>(50);
|
||||
const [offset, setOffset] = useState<number>(0);
|
||||
const [totalCount, setTotalCount] = useState<number>(0);
|
||||
const [, navigate] = useLocation();
|
||||
const { toast } = useToast();
|
||||
|
||||
// patient summary to show in header
|
||||
const [patientName, setPatientName] = useState<string | null>(null);
|
||||
const [patientPID, setPatientPID] = useState<number | null>(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: any) {
|
||||
console.error(err);
|
||||
toast?.({
|
||||
title: "Error",
|
||||
description: err.message || "Failed to load financials",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function gotoRow(r: FinancialRow) {
|
||||
const openInNewTab = (url: string) => {
|
||||
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: number) => `/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: number) {
|
||||
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: any) => sl.procedureCode)
|
||||
.filter(Boolean)
|
||||
.join(", ")
|
||||
: r.linked_payment_id
|
||||
? "No Codes Given"
|
||||
: "-";
|
||||
|
||||
const toothNumbers =
|
||||
serviceLines.length > 0
|
||||
? serviceLines
|
||||
.map((sl: any) =>
|
||||
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>
|
||||
);
|
||||
}
|
||||
420
apps/Frontend/src/components/patients/patient-form.tsx
Executable file
420
apps/Frontend/src/components/patients/patient-form.tsx
Executable file
@@ -0,0 +1,420 @@
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { forwardRef, useImperativeHandle } from "react";
|
||||
import { formatLocalDate } from "@/utils/dateUtils";
|
||||
import {
|
||||
InsertPatient,
|
||||
insertPatientSchema,
|
||||
Patient,
|
||||
PatientStatus,
|
||||
patientStatusOptions,
|
||||
UpdatePatient,
|
||||
updatePatientSchema,
|
||||
} from "@repo/db/types";
|
||||
import { z } from "zod";
|
||||
import { DateInputField } from "@/components/ui/dateInputField";
|
||||
|
||||
interface PatientFormProps {
|
||||
patient?: Patient;
|
||||
extractedInfo?: {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
dateOfBirth: string;
|
||||
insuranceId: string;
|
||||
};
|
||||
onSubmit: (data: InsertPatient | UpdatePatient) => void;
|
||||
}
|
||||
|
||||
export type PatientFormRef = {
|
||||
submit: () => void;
|
||||
};
|
||||
|
||||
export const PatientForm = forwardRef<PatientFormRef, PatientFormProps>(
|
||||
({ patient, extractedInfo, onSubmit }, ref) => {
|
||||
const { user } = useAuth();
|
||||
const isEditing = !!patient;
|
||||
|
||||
const schema = useMemo(
|
||||
() =>
|
||||
isEditing
|
||||
? updatePatientSchema
|
||||
: insertPatientSchema.extend({ userId: z.number().optional() }),
|
||||
[isEditing],
|
||||
);
|
||||
|
||||
const computedDefaultValues = useMemo(() => {
|
||||
if (isEditing && patient) {
|
||||
const { id, userId, createdAt, ...sanitizedPatient } = patient;
|
||||
return {
|
||||
...sanitizedPatient,
|
||||
dateOfBirth: patient.dateOfBirth
|
||||
? formatLocalDate(new Date(patient.dateOfBirth))
|
||||
: "",
|
||||
};
|
||||
}
|
||||
return {
|
||||
firstName: extractedInfo?.firstName || "",
|
||||
lastName: extractedInfo?.lastName || "",
|
||||
dateOfBirth: extractedInfo?.dateOfBirth || "",
|
||||
gender: "",
|
||||
phone: "",
|
||||
email: "",
|
||||
address: "",
|
||||
city: "",
|
||||
zipCode: "",
|
||||
insuranceProvider: "",
|
||||
insuranceId: extractedInfo?.insuranceId || "",
|
||||
groupNumber: "",
|
||||
policyHolder: "",
|
||||
allergies: "",
|
||||
medicalConditions: "",
|
||||
status: "UNKNOWN",
|
||||
userId: user?.id,
|
||||
};
|
||||
}, [isEditing, patient, extractedInfo, user?.id]);
|
||||
|
||||
const form = useForm<InsertPatient | UpdatePatient>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: computedDefaultValues,
|
||||
});
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
submit() {
|
||||
(
|
||||
document.getElementById("patient-form") as HTMLFormElement | null
|
||||
)?.requestSubmit();
|
||||
},
|
||||
}));
|
||||
|
||||
// Debug form errors
|
||||
useEffect(() => {
|
||||
const errors = form.formState.errors;
|
||||
if (Object.keys(errors).length > 0) {
|
||||
console.log("❌ Form validation errors:", errors);
|
||||
}
|
||||
}, [form.formState.errors]);
|
||||
|
||||
useEffect(() => {
|
||||
if (patient) {
|
||||
const { id, userId, createdAt, ...sanitizedPatient } = patient;
|
||||
const resetValues: Partial<Patient> = {
|
||||
...sanitizedPatient,
|
||||
dateOfBirth: patient.dateOfBirth
|
||||
? formatLocalDate(new Date(patient.dateOfBirth))
|
||||
: "",
|
||||
};
|
||||
form.reset(resetValues);
|
||||
}
|
||||
}, [patient, computedDefaultValues, form]);
|
||||
|
||||
const handleSubmit2 = (data: InsertPatient | UpdatePatient) => {
|
||||
onSubmit(data);
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="patient-form"
|
||||
key={patient?.id || "new"}
|
||||
onSubmit={form.handleSubmit((data) => {
|
||||
handleSubmit2(data);
|
||||
})}
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* Personal Information */}
|
||||
<div>
|
||||
<h4 className="text-md font-medium text-gray-700 mb-3">
|
||||
Personal Information
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="firstName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>First Name *</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="lastName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Last Name *</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DateInputField
|
||||
control={form.control}
|
||||
name="dateOfBirth"
|
||||
label="Date of Birth *"
|
||||
disableFuture
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="gender"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Gender *</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value as string}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select gender" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="male">Male</SelectItem>
|
||||
<SelectItem value="female">Female</SelectItem>
|
||||
<SelectItem value="other">Other</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contact Information */}
|
||||
<div>
|
||||
<h4 className="text-md font-medium text-gray-700 mb-3">
|
||||
Contact Information
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="phone"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Phone Number *</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="email"
|
||||
{...field}
|
||||
value={field.value || ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="address"
|
||||
render={({ field }) => (
|
||||
<FormItem className="md:col-span-2">
|
||||
<FormLabel>Address</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} value={field.value || ""} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="city"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>City</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} value={field.value || ""} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="zipCode"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>ZIP Code</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} value={field.value || ""} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Insurance Information */}
|
||||
<div>
|
||||
<h4 className="text-md font-medium text-gray-700 mb-3">
|
||||
Insurance Information
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="status"
|
||||
render={({ field }) => {
|
||||
const options = Object.values(
|
||||
patientStatusOptions,
|
||||
) as PatientStatus[]; // ['ACTIVE','INACTIVE','UNKNOWN']
|
||||
const toLabel = (v: PatientStatus) =>
|
||||
v[0] + v.slice(1).toLowerCase(); // ACTIVE -> Active
|
||||
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Status *</FormLabel>
|
||||
<Select
|
||||
value={(field.value as PatientStatus) ?? "UNKNOWN"}
|
||||
onValueChange={(v) =>
|
||||
field.onChange(v as PatientStatus)
|
||||
}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select status" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{options.map((v) => (
|
||||
<SelectItem key={v} value={v}>
|
||||
{toLabel(v)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="insuranceProvider"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Insurance Provider</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={(field.value as string) || ""}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select provider" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="placeholder">
|
||||
Select provider
|
||||
</SelectItem>
|
||||
<SelectItem value="Mass Health">Mass Health</SelectItem>
|
||||
<SelectItem value="Delta MA">Delta MA</SelectItem>
|
||||
<SelectItem value="Metlife">MetLife</SelectItem>
|
||||
<SelectItem value="Cigna">Cigna</SelectItem>
|
||||
<SelectItem value="Aetna">Aetna</SelectItem>
|
||||
<SelectItem value="Other">Other</SelectItem>
|
||||
<SelectItem value="none">None</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="insuranceId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Insurance ID</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} value={String(field.value) || ""} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="groupNumber"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Group Number</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} value={field.value || ""} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="policyHolder"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Policy Holder (if not self)</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} value={field.value || ""} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hidden submit button for form validation */}
|
||||
<button type="submit" className="hidden" aria-hidden="true"></button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
},
|
||||
);
|
||||
300
apps/Frontend/src/components/patients/patient-search.tsx
Executable file
300
apps/Frontend/src/components/patients/patient-search.tsx
Executable file
@@ -0,0 +1,300 @@
|
||||
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 type SearchCriteria = {
|
||||
searchTerm: string;
|
||||
searchBy: "name" | "insuranceId" | "phone" | "gender" | "dob" | "all";
|
||||
};
|
||||
|
||||
interface PatientSearchProps {
|
||||
onSearch: (criteria: SearchCriteria) => void;
|
||||
onClearSearch: () => void;
|
||||
isSearchActive: boolean;
|
||||
}
|
||||
|
||||
export function PatientSearch({
|
||||
onSearch,
|
||||
onClearSearch,
|
||||
isSearchActive,
|
||||
}: PatientSearchProps) {
|
||||
const [dobOpen, setDobOpen] = useState(false);
|
||||
const [advanceDobOpen, setAdvanceDobOpen] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [searchBy, setSearchBy] = useState<SearchCriteria["searchBy"]>("name");
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [advancedCriteria, setAdvancedCriteria] = useState<SearchCriteria>({
|
||||
searchTerm: "",
|
||||
searchBy: "name",
|
||||
});
|
||||
|
||||
const handleSearch = () => {
|
||||
onSearch({
|
||||
searchTerm,
|
||||
searchBy,
|
||||
});
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setSearchTerm("");
|
||||
setSearchBy("all");
|
||||
onClearSearch();
|
||||
};
|
||||
|
||||
const handleAdvancedSearch = () => {
|
||||
onSearch(advancedCriteria);
|
||||
setShowAdvanced(false);
|
||||
};
|
||||
|
||||
const updateAdvancedCriteria = (
|
||||
field: keyof SearchCriteria,
|
||||
value: string
|
||||
) => {
|
||||
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: Date | undefined) => {
|
||||
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 as SearchCriteria["searchBy"]);
|
||||
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 as SearchCriteria["searchBy"],
|
||||
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>
|
||||
);
|
||||
}
|
||||
1549
apps/Frontend/src/components/patients/patient-table.tsx
Executable file
1549
apps/Frontend/src/components/patients/patient-table.tsx
Executable file
File diff suppressed because it is too large
Load Diff
953
apps/Frontend/src/components/payments/payment-edit-modal.tsx
Executable file
953
apps/Frontend/src/components/payments/payment-edit-modal.tsx
Executable file
@@ -0,0 +1,953 @@
|
||||
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 {
|
||||
PaymentStatus,
|
||||
PaymentMethod,
|
||||
paymentMethodOptions,
|
||||
PaymentWithExtras,
|
||||
NewTransactionPayload,
|
||||
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 } from "@tanstack/react-query";
|
||||
|
||||
type PaymentEditModalProps = {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onClose: () => void;
|
||||
|
||||
// Keeping callbacks optional — if provided parent handlers will be used
|
||||
onEditServiceLine?: (payload: NewTransactionPayload) => void;
|
||||
isUpdatingServiceLine?: boolean;
|
||||
|
||||
onUpdateStatus?: (paymentId: number, status: PaymentStatus) => void;
|
||||
isUpdatingStatus?: boolean;
|
||||
|
||||
// Either pass a full payment object OR a paymentId (or both)
|
||||
payment?: PaymentWithExtras | null;
|
||||
paymentId?: number | null;
|
||||
};
|
||||
|
||||
export default function PaymentEditModal({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
onClose,
|
||||
onEditServiceLine,
|
||||
isUpdatingServiceLine: propUpdatingServiceLine,
|
||||
onUpdateStatus,
|
||||
isUpdatingStatus: propUpdatingStatus,
|
||||
payment: paymentProp,
|
||||
paymentId: paymentIdProp,
|
||||
}: PaymentEditModalProps) {
|
||||
// Local payment state: prefer prop but fetch if paymentId is provided
|
||||
const [payment, setPayment] = useState<PaymentWithExtras | null>(
|
||||
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<number | null>(null);
|
||||
const [paymentStatus, setPaymentStatus] = useState<PaymentStatus>(
|
||||
(paymentProp ?? null)?.status ?? ("PENDING" as PaymentStatus)
|
||||
);
|
||||
|
||||
const [formState, setFormState] = useState(() => {
|
||||
return {
|
||||
serviceLineId: 0,
|
||||
transactionId: "",
|
||||
paidAmount: 0,
|
||||
adjustedAmount: 0,
|
||||
method: paymentMethodOptions.CHECK as PaymentMethod,
|
||||
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 as PaymentWithExtras);
|
||||
setPaymentStatus((data as PaymentWithExtras).status);
|
||||
}
|
||||
} catch (err: any) {
|
||||
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?: number) {
|
||||
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 as PaymentWithExtras);
|
||||
setPaymentStatus((data as PaymentWithExtras).status);
|
||||
} catch (err: any) {
|
||||
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: NewTransactionPayload) => {
|
||||
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,
|
||||
}: {
|
||||
paymentId: number;
|
||||
status: PaymentStatus;
|
||||
}) => {
|
||||
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: NewTransactionPayload) => {
|
||||
if (onEditServiceLine) {
|
||||
await onEditServiceLine(payload);
|
||||
} else {
|
||||
// fallback to internal API call
|
||||
setLocalUpdatingServiceLine(true);
|
||||
await internalUpdatePaymentMutation.mutateAsync(payload);
|
||||
setLocalUpdatingServiceLine(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateStatus = async (
|
||||
paymentId: number,
|
||||
status: PaymentStatus
|
||||
) => {
|
||||
if (onUpdateStatus) {
|
||||
await onUpdateStatus(paymentId, status);
|
||||
} else {
|
||||
setLocalUpdatingStatus(true);
|
||||
await internalUpdatePaymentStatusMutation.mutateAsync({
|
||||
paymentId,
|
||||
status,
|
||||
});
|
||||
setLocalUpdatingStatus(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditServiceLineClick = (lineId: number) => {
|
||||
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 as PaymentMethod,
|
||||
receivedDate: formatLocalDate(new Date()),
|
||||
payerName: "",
|
||||
notes: "",
|
||||
});
|
||||
|
||||
setExpandedLineId(lineId);
|
||||
};
|
||||
|
||||
const updateField = (field: string, value: any) => {
|
||||
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: NewTransactionPayload = {
|
||||
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: (typeof serviceLines)[0]) => {
|
||||
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: NewTransactionPayload = {
|
||||
paymentId: payment.id,
|
||||
serviceLineTransactions: [
|
||||
{
|
||||
serviceLineId: line.id,
|
||||
paidAmount: dueAmount,
|
||||
adjustedAmount: 0,
|
||||
method: paymentMethodOptions.CHECK as PaymentMethod, // 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>
|
||||
) : (
|
||||
<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: PaymentStatus) =>
|
||||
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>
|
||||
</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>
|
||||
</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 as any).quad && (
|
||||
<p>
|
||||
<span className="text-gray-500">Quad:</span>{" "}
|
||||
<span className="font-medium">
|
||||
{(line as any).quad}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
{(line as any).arch && (
|
||||
<p>
|
||||
<span className="text-gray-500">Arch:</span>{" "}
|
||||
<span className="font-medium">
|
||||
{(line as any).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: PaymentMethod) =>
|
||||
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>
|
||||
);
|
||||
}
|
||||
458
apps/Frontend/src/components/payments/payment-ocr-block.tsx
Executable file
458
apps/Frontend/src/components/payments/payment-ocr-block.tsx
Executable file
@@ -0,0 +1,458 @@
|
||||
// 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,
|
||||
ColumnDef,
|
||||
} 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,
|
||||
MultipleFileUploadZoneHandle,
|
||||
} from "../file-upload/multiple-file-upload-zone";
|
||||
|
||||
// ---------------- Types ----------------
|
||||
|
||||
type Row = { __id: number } & Record<string, string | number | null>;
|
||||
|
||||
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<MultipleFileUploadZoneHandle | null>(null);
|
||||
const [filesForUI, setFilesForUI] = React.useState<File[]>([]); // 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<Row[]>([]);
|
||||
const [modalColumns, setModalColumns] = React.useState<string[]>([]);
|
||||
const [showModal, setShowModal] = React.useState(false);
|
||||
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
//Mutation
|
||||
const extractPaymentOCR = useMutation({
|
||||
mutationFn: async (files: File[]) => {
|
||||
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()) as { rows: Row[] } | Row[];
|
||||
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: Row[] = cleaned.map((r, i) => ({ ...r, __id: i }));
|
||||
setRows(withIds);
|
||||
|
||||
const allKeys = Array.from(
|
||||
cleaned.reduce<Set<string>>((acc, row) => {
|
||||
Object.keys(row).forEach((k) => acc.add(k));
|
||||
return acc;
|
||||
}, new Set())
|
||||
);
|
||||
|
||||
setModalColumns(allKeys);
|
||||
|
||||
setIsExtracting(false);
|
||||
setShowModal(true);
|
||||
},
|
||||
|
||||
onError: (error: any) => {
|
||||
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: File[]) => {
|
||||
setFilesForUI(files);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
// Remove a single file by asking the zone to remove it (zone exposes removeFile)
|
||||
const removeUploadedFile = React.useCallback((index: number) => {
|
||||
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: string[] = [];
|
||||
|
||||
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: any) {
|
||||
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,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSave: () => void;
|
||||
rows: Row[];
|
||||
setRows: React.Dispatch<React.SetStateAction<Row[]>>;
|
||||
columnKeys: string[];
|
||||
}) {
|
||||
if (!open) return null;
|
||||
|
||||
//rows helper
|
||||
const handleDeleteRow = (index: number) => {
|
||||
setRows((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleAddRow = React.useCallback(() => {
|
||||
setRows((prev) => {
|
||||
const newRow: Row = { __id: prev.length };
|
||||
columnKeys.forEach((k) => {
|
||||
newRow[k] = "";
|
||||
});
|
||||
return [...prev, newRow];
|
||||
});
|
||||
}, [setRows, columnKeys]);
|
||||
|
||||
const modalColumns = React.useMemo<ColumnDef<Row>[]>(() => {
|
||||
// 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] ?? "") as string;
|
||||
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>
|
||||
);
|
||||
}
|
||||
127
apps/Frontend/src/components/payments/payments-of-patient-table.tsx
Executable file
127
apps/Frontend/src/components/payments/payments-of-patient-table.tsx
Executable file
@@ -0,0 +1,127 @@
|
||||
import { forwardRef, useEffect, useRef, useState } from "react";
|
||||
import { PatientTable } from "../patients/patient-table";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Patient } from "@repo/db/types";
|
||||
import PaymentsRecentTable from "./payments-recent-table";
|
||||
|
||||
type Props = {
|
||||
initialPatient?: Patient | null;
|
||||
openInitially?: boolean;
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
const PaymentsOfPatientModal = forwardRef<HTMLDivElement, Props>(
|
||||
({ initialPatient = null, openInitially = false, onClose }: Props, ref) => {
|
||||
const [selectedPatient, setSelectedPatient] = useState<Patient | null>(
|
||||
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: Patient | null) => {
|
||||
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;
|
||||
839
apps/Frontend/src/components/payments/payments-recent-table.tsx
Executable file
839
apps/Frontend/src/components/payments/payments-recent-table.tsx
Executable file
@@ -0,0 +1,839 @@
|
||||
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,
|
||||
Eye,
|
||||
Delete,
|
||||
Clock,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
TrendingUp,
|
||||
ThumbsDown,
|
||||
DollarSign,
|
||||
Ban,
|
||||
} 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 {
|
||||
NewTransactionPayload,
|
||||
PaymentStatus,
|
||||
PaymentWithExtras,
|
||||
} from "@repo/db/types";
|
||||
import EditPaymentModal from "./payment-edit-modal";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { ConfirmationDialog } from "../ui/confirmationDialog";
|
||||
import { getPageNumbers } from "@/utils/pageNumberGenerator";
|
||||
|
||||
interface PaymentApiResponse {
|
||||
payments: PaymentWithExtras[];
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
interface PaymentsRecentTableProps {
|
||||
allowEdit?: boolean;
|
||||
allowDelete?: boolean;
|
||||
allowCheckbox?: boolean;
|
||||
onSelectPayment?: (payment: PaymentWithExtras | null) => void;
|
||||
onPageChange?: (page: number) => void;
|
||||
patientId?: number;
|
||||
}
|
||||
|
||||
// 🔑 exported base key (so others can invalidate all pages/filters)
|
||||
export const QK_PAYMENTS_RECENT_BASE = ["payments-recent"] as const;
|
||||
// 🔑 exported helper for specific pages/scopes
|
||||
export const qkPaymentsRecent = (opts: {
|
||||
patientId?: number | null;
|
||||
page: number;
|
||||
}) =>
|
||||
opts.patientId
|
||||
? ([
|
||||
...QK_PAYMENTS_RECENT_BASE,
|
||||
"patient",
|
||||
opts.patientId,
|
||||
opts.page,
|
||||
] as const)
|
||||
: ([...QK_PAYMENTS_RECENT_BASE, "global", opts.page] as const);
|
||||
|
||||
export default function PaymentsRecentTable({
|
||||
allowEdit,
|
||||
allowDelete,
|
||||
allowCheckbox,
|
||||
onSelectPayment,
|
||||
onPageChange,
|
||||
patientId,
|
||||
}: PaymentsRecentTableProps) {
|
||||
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<
|
||||
PaymentWithExtras | undefined
|
||||
>(undefined);
|
||||
const [selectedPaymentId, setSelectedPaymentId] = useState<number | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const [isRevertOpen, setIsRevertOpen] = useState(false);
|
||||
const [revertPaymentId, setRevertPaymentId] = useState<number | null>(null);
|
||||
|
||||
const handleSelectPayment = (payment: PaymentWithExtras) => {
|
||||
const isSelected = selectedPaymentId === payment.id;
|
||||
const newSelectedId = isSelected ? null : payment.id;
|
||||
setSelectedPaymentId(Number(newSelectedId));
|
||||
if (onSelectPayment) {
|
||||
onSelectPayment(isSelected ? null : payment);
|
||||
}
|
||||
};
|
||||
|
||||
const queryKey = qkPaymentsRecent({
|
||||
patientId: patientId ?? undefined,
|
||||
page: currentPage,
|
||||
});
|
||||
|
||||
const {
|
||||
data: paymentsData,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useQuery<PaymentApiResponse>({
|
||||
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 updatePaymentMutation = useMutation({
|
||||
mutationFn: async (data: NewTransactionPayload) => {
|
||||
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,
|
||||
}: {
|
||||
paymentId: number;
|
||||
status: PaymentStatus;
|
||||
}) => {
|
||||
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,
|
||||
}: {
|
||||
paymentId: number;
|
||||
type: "pay" | "revert";
|
||||
}) => {
|
||||
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: any) => {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: `Operation failed: ${error.message}`,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handlePayAbsoluteFullDue = (paymentId: number) => {
|
||||
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: number) => {
|
||||
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: PaymentWithExtras) => {
|
||||
setCurrentPayment(payment);
|
||||
setIsEditPaymentOpen(true);
|
||||
};
|
||||
|
||||
const handleDeletePayment = (payment: PaymentWithExtras) => {
|
||||
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: number) => {
|
||||
updatePaymentStatusMutation.mutate({ paymentId, status: "VOID" });
|
||||
};
|
||||
|
||||
const handleUnvoid = (paymentId: number) => {
|
||||
updatePaymentStatusMutation.mutate({ paymentId, status: "PENDING" });
|
||||
};
|
||||
|
||||
const [isVoidOpen, setIsVoidOpen] = useState(false);
|
||||
const [voidPaymentId, setVoidPaymentId] = useState<number | null>(null);
|
||||
|
||||
const [isUnvoidOpen, setIsUnvoidOpen] = useState(false);
|
||||
const [unvoidPaymentId, setUnvoidPaymentId] = useState<number | null>(null);
|
||||
|
||||
const handleConfirmVoid = () => {
|
||||
if (!voidPaymentId) return;
|
||||
handleVoid(voidPaymentId);
|
||||
setVoidPaymentId(null);
|
||||
setIsVoidOpen(false);
|
||||
};
|
||||
|
||||
const handleConfirmUnvoid = () => {
|
||||
if (!unvoidPaymentId) return;
|
||||
handleUnvoid(unvoidPaymentId);
|
||||
setUnvoidPaymentId(null);
|
||||
setIsUnvoidOpen(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: PaymentWithExtras) =>
|
||||
p.patient
|
||||
? `${p.patient.firstName} ${p.patient.lastName}`.trim()
|
||||
: (p.patientName ?? "Unknown");
|
||||
|
||||
const getInitials = (fullName: string) => {
|
||||
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: number) => {
|
||||
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?: PaymentStatus) => {
|
||||
switch (status) {
|
||||
case "PENDING":
|
||||
return {
|
||||
label: "Pending",
|
||||
color: "bg-yellow-100 text-yellow-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",
|
||||
color: "bg-green-100 text-green-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 as string).charAt(0).toUpperCase() +
|
||||
(status as string).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">
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{allowCheckbox && <TableHead>Select</TableHead>}
|
||||
<TableHead>Payment ID</TableHead>
|
||||
<TableHead>Claim ID</TableHead>
|
||||
<TableHead>Patient Name</TableHead>
|
||||
<TableHead>Amount</TableHead>
|
||||
<TableHead>Service Date</TableHead>
|
||||
<TableHead>Status</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 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 totalDue = Number(payment.totalDue || 0);
|
||||
|
||||
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>
|
||||
<Checkbox
|
||||
checked={selectedPaymentId === payment.id}
|
||||
onCheckedChange={() => handleSelectPayment(payment)}
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell>
|
||||
{typeof payment.id === "number"
|
||||
? `PAY-${payment.id.toString().padStart(4, "0")}`
|
||||
: "N/A"}
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
{typeof payment.claimId === "number"
|
||||
? `CLM-${payment.claimId.toString().padStart(4, "0")}`
|
||||
: "N/A"}
|
||||
</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 / Paid / Due breakdown */}
|
||||
<TableCell>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span>
|
||||
<strong>Total Billed:</strong> $
|
||||
{Number(totalBilled).toFixed(2)}
|
||||
</span>
|
||||
<span>
|
||||
<strong>Total Paid:</strong> ${totalPaid.toFixed(2)}
|
||||
</span>
|
||||
<span>
|
||||
<strong>Total Due:</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 items-center gap-2">
|
||||
{(() => {
|
||||
const { label, color, icon } = getStatusInfo(
|
||||
payment.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 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>
|
||||
)}
|
||||
|
||||
{/* When NOT PAID and NOT VOID → Pay in Full + Void */}
|
||||
{payment.status !== "PAID" &&
|
||||
payment.status !== "VOID" && (
|
||||
<>
|
||||
<Button
|
||||
variant="warning"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handlePayAbsoluteFullDue(payment.id)
|
||||
}
|
||||
>
|
||||
Pay in Full
|
||||
</Button>
|
||||
|
||||
{/* NEW: Void */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setVoidPaymentId(payment.id);
|
||||
setIsVoidOpen(true);
|
||||
}}
|
||||
>
|
||||
Void
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* When PAID → Revert */}
|
||||
{payment.status === "PAID" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setRevertPaymentId(payment.id);
|
||||
setIsRevertOpen(true);
|
||||
}}
|
||||
>
|
||||
Revert Full Due
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* When VOID → Unvoid */}
|
||||
{payment.status === "VOID" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setUnvoidPaymentId(payment.id);
|
||||
setIsUnvoidOpen(true);
|
||||
}}
|
||||
>
|
||||
Unvoid
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</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)}
|
||||
/>
|
||||
|
||||
{/* 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 as number);
|
||||
}}
|
||||
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>
|
||||
);
|
||||
}
|
||||
207
apps/Frontend/src/components/procedure/procedure-combo-buttons.tsx
Executable file
207
apps/Frontend/src/components/procedure/procedure-combo-buttons.tsx
Executable file
@@ -0,0 +1,207 @@
|
||||
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,
|
||||
}: {
|
||||
onDirectCombo: (comboKey: string) => void;
|
||||
}) {
|
||||
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,
|
||||
}: {
|
||||
onRegularCombo: (comboKey: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-4 mt-8">
|
||||
{Object.entries(COMBO_CATEGORIES).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.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,
|
||||
}: {
|
||||
title: string;
|
||||
combos: string[];
|
||||
labelMap?: Record<string, string>;
|
||||
onSelect: (id: string) => void;
|
||||
}) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
325
apps/Frontend/src/components/reports/collections-by-doctor-report.tsx
Executable file
325
apps/Frontend/src/components/reports/collections-by-doctor-report.tsx
Executable file
@@ -0,0 +1,325 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import PatientsBalancesList, { GenericRow } 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";
|
||||
|
||||
type StaffOption = { id: number; name: string };
|
||||
|
||||
function fmtCurrency(v: number) {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(v);
|
||||
}
|
||||
|
||||
export default function CollectionsByDoctorReport({
|
||||
startDate,
|
||||
endDate,
|
||||
}: {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
}) {
|
||||
const [staffId, setStaffId] = useState<string>("");
|
||||
|
||||
const perPage = 10;
|
||||
const [cursorStack, setCursorStack] = useState<(string | null)[]>([null]);
|
||||
const [cursorIndex, setCursorIndex] = useState<number>(0);
|
||||
const currentCursor = cursorStack[cursorIndex] ?? null;
|
||||
const pageIndex = cursorIndex + 1;
|
||||
|
||||
// load staffs list for selector
|
||||
const { data: staffs } = useQuery<StaffOption[], Error>({
|
||||
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<
|
||||
{
|
||||
balances: any[];
|
||||
totalCount: number;
|
||||
nextCursor: string | null;
|
||||
hasMore: boolean;
|
||||
},
|
||||
Error
|
||||
>({
|
||||
queryKey: [
|
||||
"collections-by-doctor-balances",
|
||||
staffId,
|
||||
currentCursor,
|
||||
perPage,
|
||||
startDate,
|
||||
endDate,
|
||||
],
|
||||
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);
|
||||
|
||||
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<
|
||||
{
|
||||
totalPatients: number;
|
||||
totalOutstanding: number | string;
|
||||
totalCollected: number | string;
|
||||
patientsWithBalance: number;
|
||||
},
|
||||
Error
|
||||
>({
|
||||
queryKey: ["collections-by-doctor-summary", staffId, startDate, endDate],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
if (staffId) params.set("staffId", staffId);
|
||||
if (startDate) params.set("from", startDate);
|
||||
if (endDate) params.set("to", endDate);
|
||||
|
||||
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]);
|
||||
|
||||
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: GenericRow[] = 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>
|
||||
);
|
||||
}
|
||||
71
apps/Frontend/src/components/reports/export-button.tsx
Executable file
71
apps/Frontend/src/components/reports/export-button.tsx
Executable file
@@ -0,0 +1,71 @@
|
||||
import React, { useState } from "react";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
|
||||
export default function ExportReportButton({
|
||||
reportType,
|
||||
from,
|
||||
to,
|
||||
staffId,
|
||||
className,
|
||||
labelCsv = "Download CSV",
|
||||
}: {
|
||||
reportType: string; // e.g. "collections_by_doctor" or "patients_with_balance"
|
||||
from?: string;
|
||||
to?: string;
|
||||
staffId?: number | string | null;
|
||||
className?: string;
|
||||
labelCsv?: string;
|
||||
}) {
|
||||
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: any) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
71
apps/Frontend/src/components/reports/pagination-controls.tsx
Executable file
71
apps/Frontend/src/components/reports/pagination-controls.tsx
Executable file
@@ -0,0 +1,71 @@
|
||||
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,
|
||||
}: {
|
||||
/** 1-based page index (for display). Pass cursorIndex + 1 from parent. */
|
||||
pageIndex: number;
|
||||
perPage: number;
|
||||
/** optional totalCount from backend (if provided) */
|
||||
total?: number | undefined;
|
||||
onPrev: () => void;
|
||||
onNext: () => void;
|
||||
hasPrev: boolean;
|
||||
hasNext: boolean;
|
||||
}) {
|
||||
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: any) => {
|
||||
e.preventDefault();
|
||||
if (hasPrev) onPrev();
|
||||
}}
|
||||
className={hasPrev ? "" : "pointer-events-none opacity-50"}
|
||||
/>
|
||||
</PaginationItem>
|
||||
|
||||
<div className="px-2" />
|
||||
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
href="#"
|
||||
onClick={(e: any) => {
|
||||
e.preventDefault();
|
||||
if (hasNext) onNext();
|
||||
}}
|
||||
className={hasNext ? "" : "pointer-events-none opacity-50"}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
139
apps/Frontend/src/components/reports/patients-balances-list.tsx
Executable file
139
apps/Frontend/src/components/reports/patients-balances-list.tsx
Executable file
@@ -0,0 +1,139 @@
|
||||
import React from "react";
|
||||
import { DollarSign } from "lucide-react";
|
||||
import PaginationControls from "./pagination-controls";
|
||||
|
||||
export type GenericRow = {
|
||||
id: string | number;
|
||||
name: string;
|
||||
currentBalance: number;
|
||||
totalCharges: number;
|
||||
totalPayments: number;
|
||||
};
|
||||
|
||||
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
|
||||
}: {
|
||||
rows: GenericRow[];
|
||||
reportType?: string | null;
|
||||
loading?: boolean;
|
||||
error?: string | boolean;
|
||||
emptyMessage?: string;
|
||||
// cursor props (required)
|
||||
pageIndex?: number;
|
||||
perPage?: number;
|
||||
total?: number | undefined;
|
||||
onPrev: () => void;
|
||||
onNext: () => void;
|
||||
hasPrev: boolean;
|
||||
hasNext: boolean;
|
||||
headerRight?: React.ReactNode;
|
||||
}) {
|
||||
const fmt = (v: number) =>
|
||||
new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(v);
|
||||
|
||||
const reportTypeTitle = (rt?: string | null) => {
|
||||
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>
|
||||
);
|
||||
}
|
||||
133
apps/Frontend/src/components/reports/patients-with-balance-report.tsx
Executable file
133
apps/Frontend/src/components/reports/patients-with-balance-report.tsx
Executable file
@@ -0,0 +1,133 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import type { PatientBalanceRow } from "@repo/db/types";
|
||||
import PatientsBalancesList from "./patients-balances-list";
|
||||
import ExportReportButton from "./export-button";
|
||||
|
||||
type Resp = {
|
||||
balances: PatientBalanceRow[];
|
||||
totalCount?: number; // optional
|
||||
nextCursor?: string | null;
|
||||
hasMore?: boolean;
|
||||
};
|
||||
|
||||
export default function PatientsWithBalanceReport({
|
||||
startDate,
|
||||
endDate,
|
||||
}: {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
}) {
|
||||
const balancesPerPage = 10;
|
||||
const [cursorStack, setCursorStack] = useState<(string | null)[]>([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<Resp, Error>({
|
||||
queryKey: [
|
||||
"/api/payments-reports/patient-balances",
|
||||
currentCursor,
|
||||
balancesPerPage,
|
||||
startDate,
|
||||
endDate,
|
||||
],
|
||||
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);
|
||||
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]);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
137
apps/Frontend/src/components/reports/report-config.tsx
Executable file
137
apps/Frontend/src/components/reports/report-config.tsx
Executable file
@@ -0,0 +1,137 @@
|
||||
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";
|
||||
|
||||
type ReportType =
|
||||
| "patients_with_balance"
|
||||
| "patients_no_balance"
|
||||
| "monthly_collections"
|
||||
| "collections_by_doctor"
|
||||
| "procedure_codes_by_doctor"
|
||||
| "payment_methods"
|
||||
| "insurance_vs_patient_payments"
|
||||
| "aging_report";
|
||||
|
||||
export default function ReportConfig({
|
||||
startDate,
|
||||
endDate,
|
||||
setStartDate,
|
||||
setEndDate,
|
||||
selectedReportType,
|
||||
setSelectedReportType,
|
||||
}: {
|
||||
startDate: string; // "" or "YYYY-MM-DD"
|
||||
endDate: string;
|
||||
setStartDate: (s: string) => void;
|
||||
setEndDate: (s: string) => void;
|
||||
selectedReportType: ReportType;
|
||||
setSelectedReportType: (r: ReportType) => void;
|
||||
}) {
|
||||
// Convert incoming string -> Date | null using your parseLocalDate utility.
|
||||
// parseLocalDate can throw for invalid strings, so guard with try/catch.
|
||||
let startDateObj: Date | null = null;
|
||||
if (startDate) {
|
||||
try {
|
||||
startDateObj = parseLocalDate(startDate);
|
||||
} catch {
|
||||
startDateObj = null;
|
||||
}
|
||||
}
|
||||
|
||||
let endDateObj: Date | null = null;
|
||||
if (endDate) {
|
||||
try {
|
||||
endDateObj = parseLocalDate(endDate);
|
||||
} catch {
|
||||
endDateObj = null;
|
||||
}
|
||||
}
|
||||
|
||||
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 and date range.
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 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 htmlFor="report-type">Report Type</Label>
|
||||
<Select
|
||||
value={selectedReportType}
|
||||
onValueChange={(v) => setSelectedReportType(v as ReportType)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select report type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="patients_with_balance">
|
||||
Patients with Outstanding Balance
|
||||
</SelectItem>
|
||||
<SelectItem value="collections_by_doctor">
|
||||
Collections by Doctor
|
||||
</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>
|
||||
);
|
||||
}
|
||||
115
apps/Frontend/src/components/reports/summary-cards.tsx
Executable file
115
apps/Frontend/src/components/reports/summary-cards.tsx
Executable file
@@ -0,0 +1,115 @@
|
||||
import React from "react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
|
||||
type SummaryResp = {
|
||||
totalPatients?: number;
|
||||
patientsWithBalance?: number;
|
||||
totalOutstanding?: number;
|
||||
totalCollected?: number;
|
||||
};
|
||||
|
||||
function fmtCurrency(v: number) {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(v);
|
||||
}
|
||||
|
||||
export default function SummaryCards({
|
||||
startDate,
|
||||
endDate,
|
||||
}: {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
}) {
|
||||
// Query the server summary for the given date range
|
||||
const { data, isLoading, isError } = useQuery<SummaryResp, Error>({
|
||||
queryKey: ["/api/payments-reports/summary", startDate, endDate],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
if (startDate) params.set("from", startDate);
|
||||
if (endDate) params.set("to", endDate);
|
||||
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>
|
||||
);
|
||||
}
|
||||
148
apps/Frontend/src/components/settings/InsuranceCredForm.tsx
Executable file
148
apps/Frontend/src/components/settings/InsuranceCredForm.tsx
Executable file
@@ -0,0 +1,148 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import { toast } from "@/hooks/use-toast";
|
||||
|
||||
type CredentialFormProps = {
|
||||
onClose: () => void;
|
||||
userId: number;
|
||||
defaultValues?: {
|
||||
id?: number;
|
||||
siteKey: string;
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
};
|
||||
|
||||
export function CredentialForm({ onClose, userId, defaultValues }: CredentialFormProps) {
|
||||
const [siteKey, setSiteKey] = useState(defaultValues?.siteKey || "");
|
||||
const [username, setUsername] = useState(defaultValues?.username || "");
|
||||
const [password, setPassword] = useState(defaultValues?.password || "");
|
||||
|
||||
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: any) => {
|
||||
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: React.FormEvent) => {
|
||||
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">Site Key</label>
|
||||
<input
|
||||
type="text"
|
||||
value={siteKey}
|
||||
onChange={(e) => setSiteKey(e.target.value)}
|
||||
className="mt-1 p-2 border rounded w-full"
|
||||
placeholder="e.g., MH, Delta MA, (keep the site key exact same)"
|
||||
/>
|
||||
</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>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="mt-1 p-2 border rounded w-full"
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
234
apps/Frontend/src/components/settings/insuranceCredTable.tsx
Executable file
234
apps/Frontend/src/components/settings/insuranceCredTable.tsx
Executable file
@@ -0,0 +1,234 @@
|
||||
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";
|
||||
|
||||
type Credential = {
|
||||
id: number;
|
||||
siteKey: string;
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
export function CredentialTable() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Fetch current user
|
||||
const {
|
||||
data: currentUser,
|
||||
isLoading: isUserLoading,
|
||||
isError: isUserError,
|
||||
} = useQuery({
|
||||
queryKey: ["/api/users/"],
|
||||
queryFn: async () => {
|
||||
const res = await apiRequest("GET", "/api/users/");
|
||||
if (!res.ok) throw new Error("Failed to fetch user");
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingCred, setEditingCred] = useState<Credential | null>(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() as Promise<Credential[]>;
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (cred: Credential) => {
|
||||
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<Credential | null>(null);
|
||||
|
||||
const handleDeleteClick = (cred: Credential) => {
|
||||
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);
|
||||
|
||||
if (isUserLoading) return <p>Loading user...</p>;
|
||||
if (isUserError) return <p>Error loading user</p>;
|
||||
|
||||
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 Credential
|
||||
</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>
|
||||
);
|
||||
}
|
||||
149
apps/Frontend/src/components/settings/npiProviderForm.tsx
Executable file
149
apps/Frontend/src/components/settings/npiProviderForm.tsx
Executable file
@@ -0,0 +1,149 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import { toast } from "@/hooks/use-toast";
|
||||
|
||||
type Props = {
|
||||
onClose: () => void;
|
||||
defaultValues?: {
|
||||
id?: number;
|
||||
npiNumber: string;
|
||||
providerName: string;
|
||||
};
|
||||
};
|
||||
|
||||
export function NpiProviderForm({ onClose, defaultValues }: Props) {
|
||||
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: any) => {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setNpiNumber(defaultValues?.npiNumber || "");
|
||||
setProviderName(defaultValues?.providerName || "");
|
||||
}, [defaultValues]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
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>
|
||||
);
|
||||
}
|
||||
199
apps/Frontend/src/components/settings/npiProviderTable.tsx
Executable file
199
apps/Frontend/src/components/settings/npiProviderTable.tsx
Executable file
@@ -0,0 +1,199 @@
|
||||
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 { DeleteConfirmationDialog } from "../ui/deleteDialog";
|
||||
import { NpiProviderForm } from "./npiProviderForm";
|
||||
|
||||
type NpiProvider = {
|
||||
id: number;
|
||||
npiNumber: string;
|
||||
providerName: string;
|
||||
};
|
||||
|
||||
export function NpiProviderTable() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingProvider, setEditingProvider] =
|
||||
useState<NpiProvider | null>(null);
|
||||
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [providerToDelete, setProviderToDelete] =
|
||||
useState<NpiProvider | null>(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() as Promise<NpiProvider[]>;
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (provider: NpiProvider) => {
|
||||
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 handleDeleteClick = (provider: NpiProvider) => {
|
||||
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 Provider
|
||||
</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">
|
||||
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" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{isLoading ? (
|
||||
<tr>
|
||||
<td colSpan={3} className="text-center py-4">
|
||||
Loading NPI providers...
|
||||
</td>
|
||||
</tr>
|
||||
) : error ? (
|
||||
<tr>
|
||||
<td colSpan={3} className="text-center py-4 text-red-600">
|
||||
Error loading NPI providers
|
||||
</td>
|
||||
</tr>
|
||||
) : currentProviders.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={3} className="text-center py-4">
|
||||
No NPI providers found.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
currentProviders.map((provider) => (
|
||||
<tr key={provider.id}>
|
||||
<td className="px-4 py-2">
|
||||
{provider.npiNumber}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
{provider.providerName}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
130
apps/Frontend/src/components/staffs/staff-form.tsx
Executable file
130
apps/Frontend/src/components/staffs/staff-form.tsx
Executable file
@@ -0,0 +1,130 @@
|
||||
import { Staff } from "@repo/db/types";
|
||||
import React, { useState, useEffect } from "react";
|
||||
|
||||
interface StaffFormProps {
|
||||
initialData?: Partial<Staff>;
|
||||
onSubmit: (data: Omit<Staff, "id">) => void;
|
||||
onCancel: () => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function StaffForm({
|
||||
initialData,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
isLoading,
|
||||
}: StaffFormProps) {
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [role, setRole] = useState("Staff");
|
||||
const [phone, setPhone] = useState("");
|
||||
const [userId, setUserId] = useState<number | undefined>(undefined);
|
||||
|
||||
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: React.FormEvent) => {
|
||||
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,
|
||||
userId: userId || 0,
|
||||
});
|
||||
};
|
||||
|
||||
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-blue-600 text-white rounded disabled:opacity-50"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? "Saving..." : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
239
apps/Frontend/src/components/staffs/staff-table.tsx
Executable file
239
apps/Frontend/src/components/staffs/staff-table.tsx
Executable file
@@ -0,0 +1,239 @@
|
||||
import React, { useState } from "react";
|
||||
import { Button } from "../ui/button";
|
||||
import { Delete, Edit } from "lucide-react";
|
||||
import { Staff } from "@repo/db/types";
|
||||
|
||||
interface StaffTableProps {
|
||||
staff: Staff[];
|
||||
isLoading?: boolean;
|
||||
isError?: boolean;
|
||||
onAdd: () => void;
|
||||
onEdit: (staff: Staff) => void;
|
||||
onDelete: (staff: Staff) => void;
|
||||
onView: (staff: Staff) => void;
|
||||
}
|
||||
|
||||
export function StaffTable({
|
||||
staff,
|
||||
onEdit,
|
||||
onView,
|
||||
onDelete,
|
||||
onAdd,
|
||||
}: StaffTableProps) {
|
||||
const [currentPage, setCurrentPage] = useState<number>(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: string) => {
|
||||
return name
|
||||
.split(" ")
|
||||
.map((n: string) => n[0])
|
||||
.join("")
|
||||
.toUpperCase();
|
||||
};
|
||||
|
||||
const getAvatarColor = (id: number) => {
|
||||
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: string) => {
|
||||
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-blue-600 text-white rounded hover:bg-blue-700"
|
||||
>
|
||||
Add New Staff
|
||||
</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: 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-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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
31
apps/Frontend/src/components/ui/LoadingScreen.tsx
Executable file
31
apps/Frontend/src/components/ui/LoadingScreen.tsx
Executable file
@@ -0,0 +1,31 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
56
apps/Frontend/src/components/ui/accordion.tsx
Executable file
56
apps/Frontend/src/components/ui/accordion.tsx
Executable file
@@ -0,0 +1,56 @@
|
||||
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<
|
||||
React.ElementRef<typeof AccordionPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AccordionPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn("border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AccordionItem.displayName = "AccordionItem"
|
||||
|
||||
const AccordionTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
|
||||
>(({ 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<
|
||||
React.ElementRef<typeof AccordionPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
|
||||
>(({ 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 }
|
||||
139
apps/Frontend/src/components/ui/alert-dialog.tsx
Executable file
139
apps/Frontend/src/components/ui/alert-dialog.tsx
Executable file
@@ -0,0 +1,139 @@
|
||||
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<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||
>(({ 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<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||
>(({ 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
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-2 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
AlertDialogHeader.displayName = "AlertDialogHeader"
|
||||
|
||||
const AlertDialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<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<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
|
||||
|
||||
const AlertDialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||
>(({ 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<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Action
|
||||
ref={ref}
|
||||
className={cn(buttonVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
|
||||
|
||||
const AlertDialogCancel = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||
>(({ 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,
|
||||
}
|
||||
59
apps/Frontend/src/components/ui/alert.tsx
Executable file
59
apps/Frontend/src/components/ui/alert.tsx
Executable file
@@ -0,0 +1,59 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } 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<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Alert.displayName = "Alert"
|
||||
|
||||
const AlertTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ 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<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDescription.displayName = "AlertDescription"
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
5
apps/Frontend/src/components/ui/aspect-ratio.tsx
Executable file
5
apps/Frontend/src/components/ui/aspect-ratio.tsx
Executable file
@@ -0,0 +1,5 @@
|
||||
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
|
||||
|
||||
const AspectRatio = AspectRatioPrimitive.Root
|
||||
|
||||
export { AspectRatio }
|
||||
50
apps/Frontend/src/components/ui/avatar.tsx
Executable file
50
apps/Frontend/src/components/ui/avatar.tsx
Executable file
@@ -0,0 +1,50 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Avatar = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||
>(({ 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<
|
||||
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||
>(({ 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<
|
||||
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||
>(({ 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 }
|
||||
38
apps/Frontend/src/components/ui/badge.tsx
Executable file
38
apps/Frontend/src/components/ui/badge.tsx
Executable file
@@ -0,0 +1,38 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } 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",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
115
apps/Frontend/src/components/ui/breadcrumb.tsx
Executable file
115
apps/Frontend/src/components/ui/breadcrumb.tsx
Executable file
@@ -0,0 +1,115 @@
|
||||
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<
|
||||
HTMLElement,
|
||||
React.ComponentPropsWithoutRef<"nav"> & {
|
||||
separator?: React.ReactNode
|
||||
}
|
||||
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />)
|
||||
Breadcrumb.displayName = "Breadcrumb"
|
||||
|
||||
const BreadcrumbList = React.forwardRef<
|
||||
HTMLOListElement,
|
||||
React.ComponentPropsWithoutRef<"ol">
|
||||
>(({ 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<
|
||||
HTMLLIElement,
|
||||
React.ComponentPropsWithoutRef<"li">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<li
|
||||
ref={ref}
|
||||
className={cn("inline-flex items-center gap-1.5", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
BreadcrumbItem.displayName = "BreadcrumbItem"
|
||||
|
||||
const BreadcrumbLink = React.forwardRef<
|
||||
HTMLAnchorElement,
|
||||
React.ComponentPropsWithoutRef<"a"> & {
|
||||
asChild?: boolean
|
||||
}
|
||||
>(({ 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<
|
||||
HTMLSpanElement,
|
||||
React.ComponentPropsWithoutRef<"span">
|
||||
>(({ 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
|
||||
}: React.ComponentProps<"li">) => (
|
||||
<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
|
||||
}: React.ComponentProps<"span">) => (
|
||||
<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,
|
||||
}
|
||||
59
apps/Frontend/src/components/ui/button.tsx
Executable file
59
apps/Frontend/src/components/ui/button.tsx
Executable file
@@ -0,0 +1,59 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } 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",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ 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 };
|
||||
117
apps/Frontend/src/components/ui/calendar.tsx
Executable file
117
apps/Frontend/src/components/ui/calendar.tsx
Executable file
@@ -0,0 +1,117 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { DayPicker } from "react-day-picker";
|
||||
import type { DateRange } from "react-day-picker";
|
||||
import "react-day-picker/style.css";
|
||||
|
||||
type BaseProps = Omit<
|
||||
React.ComponentProps<typeof DayPicker>,
|
||||
"mode" | "selected" | "onSelect"
|
||||
>;
|
||||
|
||||
type CalendarProps =
|
||||
| (BaseProps & {
|
||||
mode: "single";
|
||||
selected?: Date;
|
||||
onSelect?: (date: Date | undefined) => void;
|
||||
closeOnSelect?: boolean /** whether to request closing after selection (default true for single) */;
|
||||
onClose?: () => void;
|
||||
})
|
||||
| (BaseProps & {
|
||||
mode: "range";
|
||||
selected?: DateRange;
|
||||
onSelect?: (range: DateRange | undefined) => void;
|
||||
closeOnSelect?: boolean; // will close only when range is complete
|
||||
onClose?: () => void;
|
||||
})
|
||||
| (BaseProps & {
|
||||
mode: "multiple";
|
||||
selected?: Date[];
|
||||
onSelect?: (dates: Date[] | undefined) => void;
|
||||
closeOnSelect?: boolean; // default false for multi
|
||||
onClose?: () => void;
|
||||
});
|
||||
|
||||
export function Calendar(props: CalendarProps) {
|
||||
const {
|
||||
mode,
|
||||
selected,
|
||||
onSelect,
|
||||
className,
|
||||
closeOnSelect,
|
||||
onClose,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const [internalSelected, setInternalSelected] =
|
||||
useState<typeof selected>(selected);
|
||||
|
||||
useEffect(() => {
|
||||
setInternalSelected(selected);
|
||||
}, [selected]);
|
||||
|
||||
const handleSelect = (value: typeof selected) => {
|
||||
setInternalSelected(value);
|
||||
// forward original callback
|
||||
onSelect?.(value as any);
|
||||
|
||||
// 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 as DateRange | undefined;
|
||||
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 as Date | undefined}
|
||||
onSelect={handleSelect as (date: Date | undefined) => void}
|
||||
captionLayout="dropdown" // ✅ Enables month/year dropdown
|
||||
{...rest}
|
||||
/>
|
||||
)}
|
||||
{mode === "range" && (
|
||||
<DayPicker
|
||||
mode="range"
|
||||
selected={internalSelected as DateRange | undefined}
|
||||
onSelect={handleSelect as (range: DateRange | undefined) => void}
|
||||
{...rest}
|
||||
/>
|
||||
)}
|
||||
{mode === "multiple" && (
|
||||
<DayPicker
|
||||
mode="multiple"
|
||||
selected={internalSelected as Date[] | undefined}
|
||||
onSelect={handleSelect as (dates: Date[] | undefined) => void}
|
||||
{...rest}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
79
apps/Frontend/src/components/ui/card.tsx
Executable file
79
apps/Frontend/src/components/ui/card.tsx
Executable file
@@ -0,0 +1,79 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ 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<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ 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<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ 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<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ 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 }
|
||||
260
apps/Frontend/src/components/ui/carousel.tsx
Executable file
260
apps/Frontend/src/components/ui/carousel.tsx
Executable file
@@ -0,0 +1,260 @@
|
||||
import * as React from "react"
|
||||
import useEmblaCarousel, {
|
||||
type UseEmblaCarouselType,
|
||||
} from "embla-carousel-react"
|
||||
import { ArrowLeft, ArrowRight } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
type CarouselApi = UseEmblaCarouselType[1]
|
||||
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
|
||||
type CarouselOptions = UseCarouselParameters[0]
|
||||
type CarouselPlugin = UseCarouselParameters[1]
|
||||
|
||||
type CarouselProps = {
|
||||
opts?: CarouselOptions
|
||||
plugins?: CarouselPlugin
|
||||
orientation?: "horizontal" | "vertical"
|
||||
setApi?: (api: CarouselApi) => void
|
||||
}
|
||||
|
||||
type CarouselContextProps = {
|
||||
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
|
||||
api: ReturnType<typeof useEmblaCarousel>[1]
|
||||
scrollPrev: () => void
|
||||
scrollNext: () => void
|
||||
canScrollPrev: boolean
|
||||
canScrollNext: boolean
|
||||
} & CarouselProps
|
||||
|
||||
const CarouselContext = React.createContext<CarouselContextProps | null>(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<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & CarouselProps
|
||||
>(
|
||||
(
|
||||
{
|
||||
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: CarouselApi) => {
|
||||
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: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
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<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ 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<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ 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<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<typeof Button>
|
||||
>(({ 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<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<typeof Button>
|
||||
>(({ 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 {
|
||||
type CarouselApi,
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
CarouselPrevious,
|
||||
CarouselNext,
|
||||
}
|
||||
365
apps/Frontend/src/components/ui/chart.tsx
Executable file
365
apps/Frontend/src/components/ui/chart.tsx
Executable file
@@ -0,0 +1,365 @@
|
||||
"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" } as const
|
||||
|
||||
export type ChartConfig = {
|
||||
[k in string]: {
|
||||
label?: React.ReactNode
|
||||
icon?: React.ComponentType
|
||||
} & (
|
||||
| { color?: string; theme?: never }
|
||||
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||
)
|
||||
}
|
||||
|
||||
type ChartContextProps = {
|
||||
config: ChartConfig
|
||||
}
|
||||
|
||||
const ChartContext = React.createContext<ChartContextProps | null>(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<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> & {
|
||||
config: ChartConfig
|
||||
children: React.ComponentProps<
|
||||
typeof RechartsPrimitive.ResponsiveContainer
|
||||
>["children"]
|
||||
}
|
||||
>(({ 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 }: { id: string; config: ChartConfig }) => {
|
||||
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 as keyof typeof itemConfig.theme] ||
|
||||
itemConfig.color
|
||||
return color ? ` --color-${key}: ${color};` : null
|
||||
})
|
||||
.join("\n")}
|
||||
}
|
||||
`
|
||||
)
|
||||
.join("\n"),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartTooltip = RechartsPrimitive.Tooltip
|
||||
|
||||
const ChartTooltipContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||
React.ComponentProps<"div"> & {
|
||||
hideLabel?: boolean
|
||||
hideIndicator?: boolean
|
||||
indicator?: "line" | "dot" | "dashed"
|
||||
nameKey?: string
|
||||
labelKey?: string
|
||||
}
|
||||
>(
|
||||
(
|
||||
{
|
||||
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 as keyof typeof config]?.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,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<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<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> &
|
||||
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
||||
hideIcon?: boolean
|
||||
nameKey?: string
|
||||
}
|
||||
>(
|
||||
(
|
||||
{ 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: ChartConfig,
|
||||
payload: unknown,
|
||||
key: string
|
||||
) {
|
||||
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: string = key
|
||||
|
||||
if (
|
||||
key in payload &&
|
||||
typeof payload[key as keyof typeof payload] === "string"
|
||||
) {
|
||||
configLabelKey = payload[key as keyof typeof payload] as string
|
||||
} else if (
|
||||
payloadPayload &&
|
||||
key in payloadPayload &&
|
||||
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
||||
) {
|
||||
configLabelKey = payloadPayload[
|
||||
key as keyof typeof payloadPayload
|
||||
] as string
|
||||
}
|
||||
|
||||
return configLabelKey in config
|
||||
? config[configLabelKey]
|
||||
: config[key as keyof typeof config]
|
||||
}
|
||||
|
||||
export {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartStyle,
|
||||
}
|
||||
28
apps/Frontend/src/components/ui/checkbox.tsx
Executable file
28
apps/Frontend/src/components/ui/checkbox.tsx
Executable file
@@ -0,0 +1,28 @@
|
||||
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<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ 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 }
|
||||
11
apps/Frontend/src/components/ui/collapsible.tsx
Executable file
11
apps/Frontend/src/components/ui/collapsible.tsx
Executable file
@@ -0,0 +1,11 @@
|
||||
"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 }
|
||||
151
apps/Frontend/src/components/ui/command.tsx
Executable file
151
apps/Frontend/src/components/ui/command.tsx
Executable file
@@ -0,0 +1,151 @@
|
||||
import * as React from "react"
|
||||
import { type DialogProps } from "@radix-ui/react-dialog"
|
||||
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<
|
||||
React.ElementRef<typeof CommandPrimitive>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
||||
>(({ 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 }: DialogProps) => {
|
||||
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<
|
||||
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||
>(({ 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<
|
||||
React.ElementRef<typeof CommandPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||
>(({ 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<
|
||||
React.ElementRef<typeof CommandPrimitive.Empty>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
||||
>((props, ref) => (
|
||||
<CommandPrimitive.Empty
|
||||
ref={ref}
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
|
||||
|
||||
const CommandGroup = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Group>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
||||
>(({ 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<
|
||||
React.ElementRef<typeof CommandPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
||||
>(({ 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<
|
||||
React.ElementRef<typeof CommandPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
||||
>(({ 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
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
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,
|
||||
}
|
||||
44
apps/Frontend/src/components/ui/confirmationDialog.tsx
Executable file
44
apps/Frontend/src/components/ui/confirmationDialog.tsx
Executable file
@@ -0,0 +1,44 @@
|
||||
export const ConfirmationDialog = ({
|
||||
isOpen,
|
||||
title,
|
||||
message,
|
||||
confirmLabel = "Confirm",
|
||||
cancelLabel = "Cancel",
|
||||
confirmColor = "bg-blue-600 hover:bg-blue-700",
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
message: string | React.ReactNode;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
confirmColor?: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}) => {
|
||||
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>
|
||||
);
|
||||
};
|
||||
198
apps/Frontend/src/components/ui/context-menu.tsx
Executable file
198
apps/Frontend/src/components/ui/context-menu.tsx
Executable file
@@ -0,0 +1,198 @@
|
||||
import * as React from "react"
|
||||
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
|
||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ContextMenu = ContextMenuPrimitive.Root
|
||||
|
||||
const ContextMenuTrigger = ContextMenuPrimitive.Trigger
|
||||
|
||||
const ContextMenuGroup = ContextMenuPrimitive.Group
|
||||
|
||||
const ContextMenuPortal = ContextMenuPrimitive.Portal
|
||||
|
||||
const ContextMenuSub = ContextMenuPrimitive.Sub
|
||||
|
||||
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup
|
||||
|
||||
const ContextMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</ContextMenuPrimitive.SubTrigger>
|
||||
))
|
||||
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName
|
||||
|
||||
const ContextMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-context-menu-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName
|
||||
|
||||
const ContextMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Portal>
|
||||
<ContextMenuPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 max-h-[--radix-context-menu-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-80 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-context-menu-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</ContextMenuPrimitive.Portal>
|
||||
))
|
||||
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName
|
||||
|
||||
const ContextMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName
|
||||
|
||||
const ContextMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.CheckboxItem>
|
||||
))
|
||||
ContextMenuCheckboxItem.displayName =
|
||||
ContextMenuPrimitive.CheckboxItem.displayName
|
||||
|
||||
const ContextMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.RadioItem>
|
||||
))
|
||||
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName
|
||||
|
||||
const ContextMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold text-foreground",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName
|
||||
|
||||
const ContextMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName
|
||||
|
||||
const ContextMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
ContextMenuShortcut.displayName = "ContextMenuShortcut"
|
||||
|
||||
export {
|
||||
ContextMenu,
|
||||
ContextMenuTrigger,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuCheckboxItem,
|
||||
ContextMenuRadioItem,
|
||||
ContextMenuLabel,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuShortcut,
|
||||
ContextMenuGroup,
|
||||
ContextMenuPortal,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuRadioGroup,
|
||||
}
|
||||
226
apps/Frontend/src/components/ui/data-table.tsx
Executable file
226
apps/Frontend/src/components/ui/data-table.tsx
Executable file
@@ -0,0 +1,226 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ColumnDef,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
useReactTable,
|
||||
SortingState,
|
||||
getSortedRowModel,
|
||||
ColumnFiltersState,
|
||||
getFilteredRowModel,
|
||||
} from "@tanstack/react-table";
|
||||
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationEllipsis,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from "@/components/ui/pagination";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Search } from "lucide-react";
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
searchKey?: string;
|
||||
searchPlaceholder?: string;
|
||||
}
|
||||
|
||||
export function DataTable<TData, TValue>({
|
||||
columns,
|
||||
data,
|
||||
searchKey,
|
||||
searchPlaceholder = "Search...",
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const [pagination, setPagination] = useState({
|
||||
pageIndex: 0,
|
||||
pageSize: 5,
|
||||
});
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
onPaginationChange: setPagination,
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters,
|
||||
pagination,
|
||||
},
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{searchKey && (
|
||||
<div className="flex items-center relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-500" />
|
||||
<Input
|
||||
placeholder={searchPlaceholder}
|
||||
value={(table.getColumn(searchKey)?.getFilterValue() as string) ?? ""}
|
||||
onChange={(event) =>
|
||||
table.getColumn(searchKey)?.setFilterValue(event.target.value)
|
||||
}
|
||||
className="max-w-sm pl-10"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="rounded-md border overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
No results found.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center justify-end space-x-2 py-4">
|
||||
<div className="flex-1 text-sm text-muted-foreground">
|
||||
{table.getFilteredRowModel().rows.length > 0 && (
|
||||
<div>
|
||||
Showing{" "}
|
||||
<strong>
|
||||
{table.getState().pagination.pageIndex *
|
||||
table.getState().pagination.pageSize +
|
||||
1}
|
||||
</strong>{" "}
|
||||
to{" "}
|
||||
<strong>
|
||||
{Math.min(
|
||||
(table.getState().pagination.pageIndex + 1) *
|
||||
table.getState().pagination.pageSize,
|
||||
table.getFilteredRowModel().rows.length
|
||||
)}
|
||||
</strong>{" "}
|
||||
of <strong>{table.getFilteredRowModel().rows.length}</strong>{" "}
|
||||
results
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
className={!table.getCanPreviousPage() ? "opacity-50 cursor-not-allowed" : ""}
|
||||
/>
|
||||
</PaginationItem>
|
||||
|
||||
{/* Page numbers */}
|
||||
{Array.from(
|
||||
{ length: Math.min(5, table.getPageCount()) },
|
||||
(_, i) => {
|
||||
const pageIndex = i;
|
||||
const isCurrentPage =
|
||||
pageIndex === table.getState().pagination.pageIndex;
|
||||
|
||||
if (table.getPageCount() > 5 && pageIndex === 3) {
|
||||
return (
|
||||
<PaginationItem key={pageIndex}>
|
||||
<PaginationEllipsis />
|
||||
</PaginationItem>
|
||||
);
|
||||
} else if (table.getPageCount() > 5 && pageIndex === 4) {
|
||||
const lastPageIndex = table.getPageCount() - 1;
|
||||
return (
|
||||
<PaginationItem key={pageIndex}>
|
||||
<PaginationLink
|
||||
onClick={() => table.setPageIndex(lastPageIndex)}
|
||||
isActive={lastPageIndex === table.getState().pagination.pageIndex}
|
||||
>
|
||||
{lastPageIndex + 1}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<PaginationItem key={pageIndex}>
|
||||
<PaginationLink
|
||||
onClick={() => table.setPageIndex(pageIndex)}
|
||||
isActive={isCurrentPage}
|
||||
>
|
||||
{pageIndex + 1}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
);
|
||||
}
|
||||
}
|
||||
)}
|
||||
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
className={!table.getCanNextPage() ? "opacity-50 cursor-not-allowed" : ""}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
183
apps/Frontend/src/components/ui/dateInput.tsx
Executable file
183
apps/Frontend/src/components/ui/dateInput.tsx
Executable file
@@ -0,0 +1,183 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { format } from "date-fns";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { CalendarIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface DateInputProps {
|
||||
label?: string;
|
||||
value: Date | null;
|
||||
onChange: (date: Date | null) => void;
|
||||
disableFuture?: boolean;
|
||||
disablePast?: boolean;
|
||||
}
|
||||
|
||||
export function DateInput({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
disableFuture = false,
|
||||
disablePast = false,
|
||||
}: DateInputProps) {
|
||||
const [inputValue, setInputValue] = useState(
|
||||
value ? format(value, "MM/dd/yyyy") : ""
|
||||
);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (value) {
|
||||
const formatted = format(value, "MM/dd/yyyy");
|
||||
setInputValue((prev) => (prev !== formatted ? formatted : prev));
|
||||
} else {
|
||||
setInputValue("");
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const raw = e.target.value;
|
||||
|
||||
// If the user typed or pasted separators, treat as segmented input.
|
||||
if (/\D/.test(raw)) {
|
||||
// Split into digit-groups, ignoring empty groups.
|
||||
const parts = raw.split(/\D+/).filter(Boolean); // e.g. ["02","12","2003"] or ["02","231996"]
|
||||
|
||||
let mm = "";
|
||||
let dd = "";
|
||||
let yyyy = "";
|
||||
|
||||
// month is the first group (max 2 chars)
|
||||
if (parts.length >= 1) {
|
||||
mm = (parts[0] ?? "").slice(0, 2);
|
||||
}
|
||||
|
||||
if (parts.length >= 2) {
|
||||
const p1 = parts[1] ?? "";
|
||||
|
||||
if (p1.length <= 2) {
|
||||
// normal: p1 is day (1-2 digits)
|
||||
dd = p1.slice(0, 2);
|
||||
|
||||
// if there are more parts, they form the year
|
||||
if (parts.length >= 3) {
|
||||
yyyy = parts.slice(2).join("").slice(0, 4);
|
||||
}
|
||||
} else {
|
||||
// overflow: p1 contains day + start-of-year (e.g. "231996")
|
||||
dd = p1.slice(0, 2);
|
||||
const rest = p1.slice(2) + parts.slice(2).join("");
|
||||
yyyy = rest.slice(0, 4);
|
||||
}
|
||||
}
|
||||
|
||||
// Pad single-digit month/day when user has moved past them.
|
||||
if (mm.length === 1 && (dd || raw.endsWith("/"))) {
|
||||
mm = mm.padStart(2, "0");
|
||||
}
|
||||
if (dd.length === 1 && (yyyy || raw.endsWith("/"))) {
|
||||
dd = dd.padStart(2, "0");
|
||||
}
|
||||
|
||||
let display = "";
|
||||
if (mm) display += mm;
|
||||
if (dd || raw.includes("/")) {
|
||||
// add slash if there's a day or user typed a slash
|
||||
display += "/" + dd;
|
||||
}
|
||||
if (yyyy || (raw.match(/\//g) || []).length >= 2) {
|
||||
// add slash for year if there's a year or user typed two slashes
|
||||
display += "/" + yyyy;
|
||||
}
|
||||
|
||||
// Trim trailing slash if nothing after it (keeps behavior clean)
|
||||
if (display.endsWith("/") && !display.endsWith("//")) {
|
||||
// keep single trailing slash only if user typed it explicitly (raw ends with '/'),
|
||||
// otherwise remove it to avoid showing an empty trailing slash.
|
||||
if (!raw.endsWith("/")) {
|
||||
display = display.replace(/\/$/, "");
|
||||
}
|
||||
}
|
||||
|
||||
setInputValue(display);
|
||||
|
||||
// Only call onChange when we have mm + dd + 4-digit yyyy and the date is valid.
|
||||
if (mm.length > 0 && dd.length > 0 && yyyy.length === 4) {
|
||||
const parsed = new Date(+yyyy, +mm - 1, +dd);
|
||||
if (!isNaN(parsed.getTime())) {
|
||||
onChange(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// No separators — free typing digits (incremental)
|
||||
// Keep simple: mm (2) / dd (2) / yyyy (4)
|
||||
let val = raw.replace(/\D/g, "");
|
||||
|
||||
if (val.length <= 2) {
|
||||
setInputValue(val);
|
||||
} else if (val.length <= 4) {
|
||||
setInputValue(`${val.slice(0, 2)}/${val.slice(2)}`);
|
||||
} else {
|
||||
const mm = val.slice(0, 2);
|
||||
const dd = val.slice(2, 4);
|
||||
const yyyy = val.slice(4, 8); // 0..4 chars
|
||||
setInputValue(`${mm}/${dd}${yyyy ? `/${yyyy}` : ""}`);
|
||||
|
||||
if (yyyy.length === 4) {
|
||||
const parsed = new Date(+yyyy, +mm - 1, +dd);
|
||||
if (!isNaN(parsed.getTime())) {
|
||||
onChange(parsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{label && <label className="text-sm font-medium">{label}</label>}
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="MM/DD/YYYY"
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button type="button" variant="outline" className={cn("px-3")}>
|
||||
<CalendarIcon className="h-4 w-4 opacity-70" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-4">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={value ?? undefined}
|
||||
defaultMonth={value ?? undefined}
|
||||
onSelect={(date) => {
|
||||
if (date) {
|
||||
setInputValue(format(date, "MM/dd/yyyy"));
|
||||
onChange(date);
|
||||
setOpen(false);
|
||||
}
|
||||
}}
|
||||
disabled={
|
||||
disableFuture
|
||||
? { after: new Date() }
|
||||
: disablePast
|
||||
? { before: new Date() }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
apps/Frontend/src/components/ui/dateInputField.tsx
Executable file
48
apps/Frontend/src/components/ui/dateInputField.tsx
Executable file
@@ -0,0 +1,48 @@
|
||||
import { format } from "date-fns";
|
||||
import {
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { DateInput } from "@/components/ui/dateInput";
|
||||
import { parseLocalDate } from "@/utils/dateUtils";
|
||||
|
||||
interface DateInputFieldProps {
|
||||
control: any;
|
||||
name: string;
|
||||
label: string;
|
||||
disableFuture?: boolean;
|
||||
disablePast?: boolean;
|
||||
}
|
||||
|
||||
// THIS COMPONENT MADE FOR USING IN FORM FIELD INSIDE ANY FORM CONTROL, NOT AS INPUT FIELD NORMALLY.
|
||||
// Here, User can input/paste date in certain format, and also select via calendar
|
||||
export function DateInputField({
|
||||
control,
|
||||
name,
|
||||
label,
|
||||
disableFuture,
|
||||
disablePast,
|
||||
}: DateInputFieldProps) {
|
||||
return (
|
||||
<FormField
|
||||
control={control}
|
||||
name={name}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{label}</FormLabel>
|
||||
<DateInput
|
||||
value={field.value ? parseLocalDate(field.value) : null}
|
||||
onChange={(date) =>
|
||||
field.onChange(date ? format(date, "yyyy-MM-dd") : null)
|
||||
}
|
||||
disableFuture={disableFuture}
|
||||
disablePast={disablePast}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
52
apps/Frontend/src/components/ui/deleteDialog.tsx
Executable file
52
apps/Frontend/src/components/ui/deleteDialog.tsx
Executable file
@@ -0,0 +1,52 @@
|
||||
export const DeleteConfirmationDialog = ({
|
||||
isOpen,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
entityName,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
entityName?: string;
|
||||
}) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex justify-center items-center z-[9999] pointer-events-auto">
|
||||
<div
|
||||
className="bg-white p-6 rounded-md shadow-md w-[90%] max-w-md pointer-events-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2 className="text-xl font-semibold mb-4">Confirm Deletion</h2>
|
||||
|
||||
<p>
|
||||
Are you sure you want to delete <strong>{entityName}</strong>?
|
||||
</p>
|
||||
|
||||
<div className="mt-6 flex justify-end space-x-4">
|
||||
<button
|
||||
type="button"
|
||||
className="bg-gray-200 px-4 py-2 rounded hover:bg-gray-300"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCancel();
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="bg-red-600 text-white px-4 py-2 rounded hover:bg-red-700"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onConfirm();
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
122
apps/Frontend/src/components/ui/dialog.tsx
Executable file
122
apps/Frontend/src/components/ui/dialog.tsx
Executable file
@@ -0,0 +1,122 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
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}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.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}
|
||||
>
|
||||
{children}
|
||||
{/* <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close> */}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
118
apps/Frontend/src/components/ui/drawer.tsx
Executable file
118
apps/Frontend/src/components/ui/drawer.tsx
Executable file
@@ -0,0 +1,118 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Drawer as DrawerPrimitive } from "vaul"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Drawer = ({
|
||||
shouldScaleBackground = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
|
||||
<DrawerPrimitive.Root
|
||||
shouldScaleBackground={shouldScaleBackground}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
Drawer.displayName = "Drawer"
|
||||
|
||||
const DrawerTrigger = DrawerPrimitive.Trigger
|
||||
|
||||
const DrawerPortal = DrawerPrimitive.Portal
|
||||
|
||||
const DrawerClose = DrawerPrimitive.Close
|
||||
|
||||
const DrawerOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DrawerPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn("fixed inset-0 z-50 bg-black/80", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName
|
||||
|
||||
const DrawerContent = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DrawerPortal>
|
||||
<DrawerOverlay />
|
||||
<DrawerPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
|
||||
{children}
|
||||
</DrawerPrimitive.Content>
|
||||
</DrawerPortal>
|
||||
))
|
||||
DrawerContent.displayName = "DrawerContent"
|
||||
|
||||
const DrawerHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DrawerHeader.displayName = "DrawerHeader"
|
||||
|
||||
const DrawerFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DrawerFooter.displayName = "DrawerFooter"
|
||||
|
||||
const DrawerTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DrawerPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DrawerTitle.displayName = DrawerPrimitive.Title.displayName
|
||||
|
||||
const DrawerDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DrawerPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DrawerDescription.displayName = DrawerPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Drawer,
|
||||
DrawerPortal,
|
||||
DrawerOverlay,
|
||||
DrawerTrigger,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerFooter,
|
||||
DrawerTitle,
|
||||
DrawerDescription,
|
||||
}
|
||||
198
apps/Frontend/src/components/ui/dropdown-menu.tsx
Executable file
198
apps/Frontend/src/components/ui/dropdown-menu.tsx
Executable file
@@ -0,0 +1,198 @@
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
))
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
))
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
))
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
))
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
}
|
||||
178
apps/Frontend/src/components/ui/form.tsx
Executable file
178
apps/Frontend/src/components/ui/form.tsx
Executable file
@@ -0,0 +1,178 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import {
|
||||
Controller,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
type ControllerProps,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
} from "react-hook-form"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label } from "@/components/ui/label"
|
||||
|
||||
const Form = FormProvider
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
> = {
|
||||
name: TName
|
||||
}
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue
|
||||
)
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext)
|
||||
const itemContext = React.useContext(FormItemContext)
|
||||
const { getFieldState, formState } = useFormContext()
|
||||
|
||||
const fieldState = getFieldState(fieldContext.name, formState)
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error("useFormField should be used within <FormField>")
|
||||
}
|
||||
|
||||
const { id } = itemContext
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
}
|
||||
}
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string
|
||||
}
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue
|
||||
)
|
||||
|
||||
const FormItem = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const id = React.useId()
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div ref={ref} className={cn("space-y-2", className)} {...props} />
|
||||
</FormItemContext.Provider>
|
||||
)
|
||||
})
|
||||
FormItem.displayName = "FormItem"
|
||||
|
||||
const FormLabel = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { error, formItemId } = useFormField()
|
||||
|
||||
return (
|
||||
<Label
|
||||
ref={ref}
|
||||
className={cn(error && "text-destructive", className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormLabel.displayName = "FormLabel"
|
||||
|
||||
const FormControl = React.forwardRef<
|
||||
React.ElementRef<typeof Slot>,
|
||||
React.ComponentPropsWithoutRef<typeof Slot>
|
||||
>(({ ...props }, ref) => {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||
|
||||
return (
|
||||
<Slot
|
||||
ref={ref}
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormControl.displayName = "FormControl"
|
||||
|
||||
const FormDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { formDescriptionId } = useFormField()
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formDescriptionId}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormDescription.displayName = "FormDescription"
|
||||
|
||||
const FormMessage = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
const { error, formMessageId } = useFormField()
|
||||
const body = error ? String(error?.message ?? "") : children
|
||||
|
||||
if (!body) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formMessageId}
|
||||
className={cn("text-sm font-medium text-destructive", className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
)
|
||||
})
|
||||
FormMessage.displayName = "FormMessage"
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
}
|
||||
29
apps/Frontend/src/components/ui/hover-card.tsx
Executable file
29
apps/Frontend/src/components/ui/hover-card.tsx
Executable file
@@ -0,0 +1,29 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const HoverCard = HoverCardPrimitive.Root
|
||||
|
||||
const HoverCardTrigger = HoverCardPrimitive.Trigger
|
||||
|
||||
const HoverCardContent = React.forwardRef<
|
||||
React.ElementRef<typeof HoverCardPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<HoverCardPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-hover-card-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
|
||||
|
||||
export { HoverCard, HoverCardTrigger, HoverCardContent }
|
||||
62
apps/Frontend/src/components/ui/input-otp.tsx
Executable file
62
apps/Frontend/src/components/ui/input-otp.tsx
Executable file
@@ -0,0 +1,62 @@
|
||||
import * as React from "react"
|
||||
import { OTPInput, OTPInputContext } from "input-otp"
|
||||
import { Dot } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const InputOTP = React.forwardRef<
|
||||
React.ElementRef<typeof OTPInput>,
|
||||
React.ComponentPropsWithoutRef<typeof OTPInput>
|
||||
>(({ className, containerClassName, ...props }, ref) => (
|
||||
<OTPInput
|
||||
ref={ref}
|
||||
containerClassName={cn(
|
||||
"flex items-center gap-2 has-[:disabled]:opacity-50",
|
||||
containerClassName
|
||||
)}
|
||||
className={cn("disabled:cursor-not-allowed", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
InputOTP.displayName = "InputOTP"
|
||||
|
||||
const InputOTPGroup = React.forwardRef<
|
||||
React.ElementRef<"div">,
|
||||
React.ComponentPropsWithoutRef<"div">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("flex items-center", className)} {...props} />
|
||||
))
|
||||
InputOTPGroup.displayName = "InputOTPGroup"
|
||||
|
||||
const InputOTPSlot = React.forwardRef<
|
||||
React.ElementRef<"div">,
|
||||
React.ComponentPropsWithoutRef<"div"> & { index: number }
|
||||
>(({ index, className, ...props }, ref) => {
|
||||
const inputOTPContext = React.useContext(OTPInputContext)
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex h-10 w-10 items-center justify-center border-y border-r border-input text-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{inputOTPContext?.slots?.[index]?.char || ''}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
InputOTPSlot.displayName = "InputOTPSlot"
|
||||
|
||||
const InputOTPSeparator = React.forwardRef<
|
||||
React.ElementRef<"div">,
|
||||
React.ComponentPropsWithoutRef<"div">
|
||||
>(({ ...props }, ref) => (
|
||||
<div ref={ref} role="separator" {...props}>
|
||||
<Dot />
|
||||
</div>
|
||||
))
|
||||
InputOTPSeparator.displayName = "InputOTPSeparator"
|
||||
|
||||
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }
|
||||
22
apps/Frontend/src/components/ui/input.tsx
Executable file
22
apps/Frontend/src/components/ui/input.tsx
Executable file
@@ -0,0 +1,22 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
24
apps/Frontend/src/components/ui/label.tsx
Executable file
24
apps/Frontend/src/components/ui/label.tsx
Executable file
@@ -0,0 +1,24 @@
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
)
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
||||
256
apps/Frontend/src/components/ui/menubar.tsx
Executable file
256
apps/Frontend/src/components/ui/menubar.tsx
Executable file
@@ -0,0 +1,256 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as MenubarPrimitive from "@radix-ui/react-menubar"
|
||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function MenubarMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Menu>) {
|
||||
return <MenubarPrimitive.Menu {...props} />
|
||||
}
|
||||
|
||||
function MenubarGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Group>) {
|
||||
return <MenubarPrimitive.Group {...props} />
|
||||
}
|
||||
|
||||
function MenubarPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Portal>) {
|
||||
return <MenubarPrimitive.Portal {...props} />
|
||||
}
|
||||
|
||||
function MenubarRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.RadioGroup>) {
|
||||
return <MenubarPrimitive.RadioGroup {...props} />
|
||||
}
|
||||
|
||||
function MenubarSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Sub>) {
|
||||
return <MenubarPrimitive.Sub data-slot="menubar-sub" {...props} />
|
||||
}
|
||||
|
||||
const Menubar = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<MenubarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 items-center space-x-1 rounded-md border bg-background p-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Menubar.displayName = MenubarPrimitive.Root.displayName
|
||||
|
||||
const MenubarTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<MenubarPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-3 py-1.5 text-sm font-medium outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName
|
||||
|
||||
const MenubarSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<MenubarPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</MenubarPrimitive.SubTrigger>
|
||||
))
|
||||
MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName
|
||||
|
||||
const MenubarSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<MenubarPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-menubar-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName
|
||||
|
||||
const MenubarContent = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Content>
|
||||
>(
|
||||
(
|
||||
{ className, align = "start", alignOffset = -4, sideOffset = 8, ...props },
|
||||
ref
|
||||
) => (
|
||||
<MenubarPrimitive.Portal>
|
||||
<MenubarPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-menubar-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</MenubarPrimitive.Portal>
|
||||
)
|
||||
)
|
||||
MenubarContent.displayName = MenubarPrimitive.Content.displayName
|
||||
|
||||
const MenubarItem = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<MenubarPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
MenubarItem.displayName = MenubarPrimitive.Item.displayName
|
||||
|
||||
const MenubarCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<MenubarPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<MenubarPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</MenubarPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenubarPrimitive.CheckboxItem>
|
||||
))
|
||||
MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName
|
||||
|
||||
const MenubarRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<MenubarPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<MenubarPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</MenubarPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenubarPrimitive.RadioItem>
|
||||
))
|
||||
MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName
|
||||
|
||||
const MenubarLabel = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<MenubarPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
MenubarLabel.displayName = MenubarPrimitive.Label.displayName
|
||||
|
||||
const MenubarSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<MenubarPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName
|
||||
|
||||
const MenubarShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
MenubarShortcut.displayname = "MenubarShortcut"
|
||||
|
||||
export {
|
||||
Menubar,
|
||||
MenubarMenu,
|
||||
MenubarTrigger,
|
||||
MenubarContent,
|
||||
MenubarItem,
|
||||
MenubarSeparator,
|
||||
MenubarLabel,
|
||||
MenubarCheckboxItem,
|
||||
MenubarRadioGroup,
|
||||
MenubarRadioItem,
|
||||
MenubarPortal,
|
||||
MenubarSubContent,
|
||||
MenubarSubTrigger,
|
||||
MenubarGroup,
|
||||
MenubarSub,
|
||||
MenubarShortcut,
|
||||
}
|
||||
128
apps/Frontend/src/components/ui/navigation-menu.tsx
Executable file
128
apps/Frontend/src/components/ui/navigation-menu.tsx
Executable file
@@ -0,0 +1,128 @@
|
||||
import * as React from "react"
|
||||
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
|
||||
import { cva } from "class-variance-authority"
|
||||
import { ChevronDown } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const NavigationMenu = React.forwardRef<
|
||||
React.ElementRef<typeof NavigationMenuPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<NavigationMenuPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-10 flex max-w-max flex-1 items-center justify-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<NavigationMenuViewport />
|
||||
</NavigationMenuPrimitive.Root>
|
||||
))
|
||||
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName
|
||||
|
||||
const NavigationMenuList = React.forwardRef<
|
||||
React.ElementRef<typeof NavigationMenuPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<NavigationMenuPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"group flex flex-1 list-none items-center justify-center space-x-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName
|
||||
|
||||
const NavigationMenuItem = NavigationMenuPrimitive.Item
|
||||
|
||||
const navigationMenuTriggerStyle = cva(
|
||||
"group inline-flex h-10 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[state=open]:text-accent-foreground data-[state=open]:bg-accent/50 data-[state=open]:hover:bg-accent data-[state=open]:focus:bg-accent"
|
||||
)
|
||||
|
||||
const NavigationMenuTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof NavigationMenuPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<NavigationMenuPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(navigationMenuTriggerStyle(), "group", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}{" "}
|
||||
<ChevronDown
|
||||
className="relative top-[1px] ml-1 h-3 w-3 transition duration-200 group-data-[state=open]:rotate-180"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</NavigationMenuPrimitive.Trigger>
|
||||
))
|
||||
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName
|
||||
|
||||
const NavigationMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof NavigationMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<NavigationMenuPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto ",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName
|
||||
|
||||
const NavigationMenuLink = NavigationMenuPrimitive.Link
|
||||
|
||||
const NavigationMenuViewport = React.forwardRef<
|
||||
React.ElementRef<typeof NavigationMenuPrimitive.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className={cn("absolute left-0 top-full flex justify-center")}>
|
||||
<NavigationMenuPrimitive.Viewport
|
||||
className={cn(
|
||||
"origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
NavigationMenuViewport.displayName =
|
||||
NavigationMenuPrimitive.Viewport.displayName
|
||||
|
||||
const NavigationMenuIndicator = React.forwardRef<
|
||||
React.ElementRef<typeof NavigationMenuPrimitive.Indicator>,
|
||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Indicator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<NavigationMenuPrimitive.Indicator
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
|
||||
</NavigationMenuPrimitive.Indicator>
|
||||
))
|
||||
NavigationMenuIndicator.displayName =
|
||||
NavigationMenuPrimitive.Indicator.displayName
|
||||
|
||||
export {
|
||||
navigationMenuTriggerStyle,
|
||||
NavigationMenu,
|
||||
NavigationMenuList,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuContent,
|
||||
NavigationMenuTrigger,
|
||||
NavigationMenuLink,
|
||||
NavigationMenuIndicator,
|
||||
NavigationMenuViewport,
|
||||
}
|
||||
123
apps/Frontend/src/components/ui/pagination.tsx
Executable file
123
apps/Frontend/src/components/ui/pagination.tsx
Executable file
@@ -0,0 +1,123 @@
|
||||
import * as React from "react"
|
||||
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ButtonProps, buttonVariants } from "@/components/ui/button"
|
||||
|
||||
const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
|
||||
<nav
|
||||
role="navigation"
|
||||
aria-label="pagination"
|
||||
className={cn("mx-auto flex w-full justify-center", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
Pagination.displayName = "Pagination"
|
||||
|
||||
const PaginationContent = React.forwardRef<
|
||||
HTMLUListElement,
|
||||
React.ComponentProps<"ul">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ul
|
||||
ref={ref}
|
||||
className={cn("flex flex-row items-center gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
PaginationContent.displayName = "PaginationContent"
|
||||
|
||||
const PaginationItem = React.forwardRef<
|
||||
HTMLLIElement,
|
||||
React.ComponentProps<"li">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<li ref={ref} className={cn("", className)} {...props} />
|
||||
))
|
||||
PaginationItem.displayName = "PaginationItem"
|
||||
|
||||
type PaginationLinkProps = {
|
||||
isActive?: boolean
|
||||
disabled?: boolean
|
||||
} & Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, 'disabled'> &
|
||||
Pick<ButtonProps, "size">
|
||||
|
||||
const PaginationLink = ({
|
||||
className,
|
||||
isActive,
|
||||
disabled,
|
||||
size = "icon",
|
||||
...props
|
||||
}: PaginationLinkProps) => (
|
||||
<a
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
aria-disabled={disabled}
|
||||
className={cn(
|
||||
isActive
|
||||
? buttonVariants({ variant: "outline" })
|
||||
: buttonVariants({ variant: "ghost" }),
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
pointerEvents: disabled ? 'none' : 'auto',
|
||||
opacity: disabled ? 0.5 : 1
|
||||
}}
|
||||
{...(props as React.AnchorHTMLAttributes<HTMLAnchorElement>)}
|
||||
/>
|
||||
)
|
||||
PaginationLink.displayName = "PaginationLink"
|
||||
|
||||
const PaginationPrevious = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) => (
|
||||
<PaginationLink
|
||||
aria-label="Go to previous page"
|
||||
size="default"
|
||||
className={cn("gap-1 pl-2.5", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
<span>Previous</span>
|
||||
</PaginationLink>
|
||||
)
|
||||
PaginationPrevious.displayName = "PaginationPrevious"
|
||||
|
||||
const PaginationNext = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) => (
|
||||
<PaginationLink
|
||||
aria-label="Go to next page"
|
||||
size="default"
|
||||
className={cn("gap-1 pr-2.5", className)}
|
||||
{...props}
|
||||
>
|
||||
<span>Next</span>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</PaginationLink>
|
||||
)
|
||||
PaginationNext.displayName = "PaginationNext"
|
||||
|
||||
const PaginationEllipsis = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) => (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn("flex h-9 w-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<span className="sr-only">More pages</span>
|
||||
</span>
|
||||
)
|
||||
PaginationEllipsis.displayName = "PaginationEllipsis"
|
||||
|
||||
export {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationEllipsis,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
}
|
||||
29
apps/Frontend/src/components/ui/popover.tsx
Executable file
29
apps/Frontend/src/components/ui/popover.tsx
Executable file
@@ -0,0 +1,29 @@
|
||||
import * as React from "react"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Popover = PopoverPrimitive.Root
|
||||
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-popover-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
))
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent }
|
||||
28
apps/Frontend/src/components/ui/progress.tsx
Executable file
28
apps/Frontend/src/components/ui/progress.tsx
Executable file
@@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Progress = React.forwardRef<
|
||||
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
|
||||
>(({ className, value, ...props }, ref) => (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className="h-full w-full flex-1 bg-primary transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
))
|
||||
Progress.displayName = ProgressPrimitive.Root.displayName
|
||||
|
||||
export { Progress }
|
||||
42
apps/Frontend/src/components/ui/radio-group.tsx
Executable file
42
apps/Frontend/src/components/ui/radio-group.tsx
Executable file
@@ -0,0 +1,42 @@
|
||||
import * as React from "react"
|
||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
|
||||
import { Circle } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const RadioGroup = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
className={cn("grid gap-2", className)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
)
|
||||
})
|
||||
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
|
||||
|
||||
const RadioGroupItem = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
|
||||
<Circle className="h-2.5 w-2.5 fill-current text-current" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
)
|
||||
})
|
||||
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
|
||||
|
||||
export { RadioGroup, RadioGroupItem }
|
||||
45
apps/Frontend/src/components/ui/resizable.tsx
Executable file
45
apps/Frontend/src/components/ui/resizable.tsx
Executable file
@@ -0,0 +1,45 @@
|
||||
"use client"
|
||||
|
||||
import { GripVertical } from "lucide-react"
|
||||
import * as ResizablePrimitive from "react-resizable-panels"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ResizablePanelGroup = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
|
||||
<ResizablePrimitive.PanelGroup
|
||||
className={cn(
|
||||
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
const ResizablePanel = ResizablePrimitive.Panel
|
||||
|
||||
const ResizableHandle = ({
|
||||
withHandle,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
|
||||
withHandle?: boolean
|
||||
}) => (
|
||||
<ResizablePrimitive.PanelResizeHandle
|
||||
className={cn(
|
||||
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{withHandle && (
|
||||
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
|
||||
<GripVertical className="h-2.5 w-2.5" />
|
||||
</div>
|
||||
)}
|
||||
</ResizablePrimitive.PanelResizeHandle>
|
||||
)
|
||||
|
||||
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
|
||||
46
apps/Frontend/src/components/ui/scroll-area.tsx
Executable file
46
apps/Frontend/src/components/ui/scroll-area.tsx
Executable file
@@ -0,0 +1,46 @@
|
||||
import * as React from "react"
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative overflow-hidden", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
))
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
|
||||
|
||||
const ScrollBar = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
>(({ className, orientation = "vertical", ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none select-none transition-colors",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent p-[1px]",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
))
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
160
apps/Frontend/src/components/ui/select.tsx
Executable file
160
apps/Frontend/src/components/ui/select.tsx
Executable file
@@ -0,0 +1,160 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
))
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
))
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user