Files
gg-backend/frontend/src/pages/Appointment.tsx
T
2026-07-02 16:48:25 +05:30

401 lines
15 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useCallback } from 'react';
import { getAppointmentsApi, deleteAppointmentApi } from '@/api/appointment';
import { exportToExcel } from '@/utils/exportToExcel';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Loader2, Trash, RefreshCw, Download, ChevronLeft, ChevronRight, Eye, CalendarDays } from 'lucide-react';
export default function AppointmentPage() {
const [appointments, setAppointments] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [searchText, setSearchText] = useState('');
const [filterDoctor, setFilterDoctor] = useState('');
const [dateMode, setDateMode] = useState<'scheduled' | 'booked'>('scheduled');
const [filterDate, setFilterDate] = useState('');
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [createdDate, setCreatedDate] = useState('');
const [createdStartDate, setCreatedStartDate] = useState('');
const [createdEndDate, setCreatedEndDate] = useState('');
const [viewOpen, setViewOpen] = useState(false);
const [viewData, setViewData] = useState<any>(null);
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalItems, setTotalItems] = useState(0);
const [itemsPerPage, setItemsPerPage] = useState(10);
const fetchAll = useCallback(async () => {
setLoading(true);
try {
const res = await getAppointmentsApi(
currentPage,
itemsPerPage,
filterDate,
startDate,
endDate,
createdDate,
createdStartDate,
createdEndDate,
searchText
);
setAppointments(res?.data || []);
setTotalPages(res?.pagination?.totalPages || 1);
setTotalItems(res?.pagination?.total || 0);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
}, [
currentPage,
itemsPerPage,
filterDate,
startDate,
endDate,
createdDate,
createdStartDate,
createdEndDate,
searchText,
]);
useEffect(() => {
fetchAll();
}, [fetchAll]);
const filteredAppointments = appointments.filter((item) => {
const matchesDoctor = filterDoctor ? item.doctor?.name?.toLowerCase().includes(filterDoctor.toLowerCase()) : true;
return matchesDoctor;
});
const handleModeChange = (newMode: 'scheduled' | 'booked') => {
setDateMode(newMode);
setFilterDate('');
setStartDate('');
setEndDate('');
setCreatedDate('');
setCreatedStartDate('');
setCreatedEndDate('');
setCurrentPage(1);
};
useEffect(() => {
setCurrentPage(1);
}, [searchText, filterDoctor, filterDate, startDate, endDate, createdDate, createdStartDate, createdEndDate]);
const indexOfFirstItem = (currentPage - 1) * itemsPerPage;
function openView(item: any) {
setViewData(item);
setViewOpen(true);
}
async function handleDelete(id: number) {
if (!confirm('Delete appointment?')) return;
await deleteAppointmentApi(id);
fetchAll();
}
const handleExport = () => {
const exportData = filteredAppointments.map((item) => ({
ID: item.id,
Name: item.name,
Phone: item.mobileNumber,
Email: item.email,
Doctor: item.doctor?.name,
Department: item.department?.name,
ScheduledDate: new Date(item.date).toLocaleDateString(),
BookedDate: new Date(item.createdAt).toLocaleDateString(),
Message: item.message,
}));
exportToExcel(exportData, 'appointments');
};
return (
<div className="p-6 space-y-6">
<div className="flex flex-col md:flex-row md:justify-between md:items-center gap-4">
<h1 className="text-3xl font-bold tracking-tight">Appointments</h1>
<div className="flex items-center gap-3">
<Button variant="outline" onClick={fetchAll} disabled={loading} className="text-base">
<RefreshCw className="mr-2 h-5 w-5" />
Refresh
</Button>
<Button onClick={handleExport} className="text-base">
<Download className="mr-2 h-5 w-5" />
Export
</Button>
</div>
</div>
<Card className="shadow-sm">
<CardContent className="p-4 flex flex-wrap xl:flex-nowrap items-end gap-4">
<div className="flex flex-col gap-1.5 flex-1 min-w-[240px]">
<label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Search Target
</label>
<Input
placeholder="Search patient name, phone, or email..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="text-base h-10"
/>
</div>
<div className="flex flex-col gap-1.5 w-[180px]">
<label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider flex items-center gap-1">
<CalendarDays className="h-3.5 w-3.5 text-primary" /> Filter Date By
</label>
<select
value={dateMode}
onChange={(e) => handleModeChange(e.target.value as 'scheduled' | 'booked')}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-medium focus:ring-2 focus:ring-primary focus:outline-none"
>
<option value="scheduled">🗓 Scheduled Date</option>
<option value="booked"> Booked (Created At)</option>
</select>
</div>
<div className="flex items-end gap-2 p-2 bg-muted/40 rounded-md border border-dashed flex-wrap sm:flex-nowrap flex-1 min-w-[320px]">
<div className="flex flex-col gap-1 flex-1 min-w-[110px]">
<span className="text-[10px] font-bold text-muted-foreground uppercase">Specific Day</span>
<Input
type="date"
value={dateMode === 'scheduled' ? filterDate : createdDate}
onChange={(e) =>
dateMode === 'scheduled' ? setFilterDate(e.target.value) : setCreatedDate(e.target.value)
}
className="text-sm h-8 bg-background"
disabled={dateMode === 'scheduled' ? !!startDate || !!endDate : !!createdStartDate || !!createdEndDate}
/>
</div>
<div className="flex flex-col gap-1 flex-1 min-w-[110px]">
<span className="text-[10px] font-bold text-muted-foreground uppercase">From Range</span>
<Input
type="date"
value={dateMode === 'scheduled' ? startDate : createdStartDate}
onChange={(e) =>
dateMode === 'scheduled' ? setStartDate(e.target.value) : setCreatedStartDate(e.target.value)
}
className="text-sm h-8 bg-background"
disabled={dateMode === 'scheduled' ? !!filterDate : !!createdDate}
/>
</div>
<div className="flex flex-col gap-1 flex-1 min-w-[110px]">
<span className="text-[10px] font-bold text-muted-foreground uppercase">To Range</span>
<Input
type="date"
value={dateMode === 'scheduled' ? endDate : createdEndDate}
onChange={(e) =>
dateMode === 'scheduled' ? setEndDate(e.target.value) : setCreatedEndDate(e.target.value)
}
className="text-sm h-8 bg-background"
disabled={dateMode === 'scheduled' ? !!filterDate : !!createdDate}
/>
</div>
</div>
<div className="flex flex-col gap-1.5 w-[95px]">
<label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Page Limit</label>
<select
value={itemsPerPage}
onChange={(e) => setItemsPerPage(Number(e.target.value))}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:ring-2 focus:ring-primary focus:outline-none"
>
<option value={5}>5 Rows</option>
<option value={10}>10 Rows</option>
<option value={20}>20 Rows</option>
</select>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="px-6 py-4 border-b">
<CardTitle className="text-xl flex items-center gap-2">
<span>Appointment Registrations</span>
</CardTitle>
</CardHeader>
<CardContent className="p-0 sm:p-6 space-y-4">
<div className="rounded-md border overflow-x-auto overflow-y-auto max-h-[650px] relative">
<Table className="w-full min-w-[1100px] table-fixed border-separate border-spacing-0">
<TableHeader className="sticky top-0 z-20 bg-background shadow-sm">
<TableRow>
<TableHead className="w-[60px] bg-background font-bold text-sm">ID</TableHead>
<TableHead className="w-[200px] bg-background font-bold text-sm">Patient</TableHead>
<TableHead className="w-[180px] bg-background font-bold text-sm">Doctor</TableHead>
<TableHead className="w-[140px] bg-background font-bold text-sm">Scheduled Date</TableHead>
<TableHead className="w-[140px] bg-background font-bold text-sm">Booked Date</TableHead>
<TableHead className="w-[200px] bg-background font-bold text-sm">Message</TableHead>
<TableHead className="w-[100px] bg-background font-bold text-right text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-10">
<Loader2 className="h-8 w-8 animate-spin mx-auto text-primary" />
</TableCell>
</TableRow>
) : filteredAppointments.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center text-muted-foreground py-10 text-base">
No appointments found matching your selected criteria.
</TableCell>
</TableRow>
) : (
filteredAppointments.map((item) => (
<TableRow key={item.id} className="hover:bg-muted/50 transition-colors">
<TableCell className="font-mono text-xs">{item.id}</TableCell>
<TableCell>
<div className="font-semibold text-base truncate">{item.name}</div>
<div className="text-xs text-muted-foreground">{item.mobileNumber}</div>
</TableCell>
<TableCell>
<div className="text-sm font-medium">{item.doctor?.name || '-'}</div>
<div className="text-[10px] text-muted-foreground truncate">{item.department?.name}</div>
</TableCell>
<TableCell>
<div
className={`text-sm font-semibold rounded px-1.5 py-0.5 inline-block ${dateMode === 'scheduled' ? 'bg-primary/10 text-primary' : 'bg-muted'}`}
>
{new Date(item.date).toLocaleDateString()}
</div>
</TableCell>
<TableCell>
<div
className={`text-sm rounded px-1.5 py-0.5 inline-block ${dateMode === 'booked' ? 'bg-sky-500/10 text-sky-600 font-semibold' : 'text-muted-foreground'}`}
>
{new Date(item.createdAt).toLocaleDateString()}
</div>
</TableCell>
<TableCell>
<div className="text-sm line-clamp-2 text-muted-foreground italic">{item.message || '-'}</div>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button size="icon" variant="ghost" className="h-9 w-9" onClick={() => openView(item)}>
<Eye className="h-4 w-4" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-9 w-9 text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => handleDelete(item.id)}
>
<Trash className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{!loading && totalItems > 0 && (
<div className="flex items-center justify-between px-2 py-4 border-t">
<div className="text-sm text-muted-foreground">
Showing <span className="font-semibold">{indexOfFirstItem + 1}</span> to{' '}
<span className="font-semibold">{Math.min(currentPage * itemsPerPage, totalItems)}</span> of{' '}
<span className="font-semibold">{totalItems}</span> entries
</div>
<div className="flex items-center gap-6">
<div className="text-sm font-medium">
Page {currentPage} of {totalPages}
</div>
<div className="flex gap-1">
<Button
variant="outline"
size="icon"
className="h-9 w-9"
onClick={() => setCurrentPage((prev) => Math.max(prev - 1, 1))}
disabled={currentPage === 1}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
className="h-9 w-9"
onClick={() => setCurrentPage((prev) => Math.min(prev + 1, totalPages))}
disabled={currentPage === totalPages || totalPages === 0}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
</div>
)}
</CardContent>
</Card>
<Dialog open={viewOpen} onOpenChange={setViewOpen}>
<DialogContent className="w-full !max-w-3xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-2xl border-b pb-2">Appointment Details</DialogTitle>
</DialogHeader>
{viewData && (
<div className="space-y-6 py-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-4">
<div>
<p className="text-xs uppercase font-bold text-muted-foreground">Patient Information</p>
<p className="text-lg font-bold text-primary">{viewData.name}</p>
<p className="text-sm">{viewData.mobileNumber}</p>
<p className="text-sm">{viewData.email || 'No email provided'}</p>
</div>
<div>
<p className="text-xs uppercase font-bold text-muted-foreground">Key Datestamps</p>
<p className="text-base font-semibold text-primary">
Scheduled: {new Date(viewData.date).toLocaleDateString()}
</p>
<p className="text-xs text-muted-foreground">
Record Created: {new Date(viewData.createdAt).toLocaleString()}
</p>
</div>
</div>
<div className="space-y-4">
<div>
<p className="text-xs uppercase font-bold text-muted-foreground">Doctor / Department</p>
<p className="text-base font-bold">{viewData.doctor?.name || 'Not Assigned'}</p>
<p className="text-sm text-muted-foreground">{viewData.department?.name || 'General'}</p>
</div>
<div className="p-4 bg-muted/30 rounded-lg">
<p className="text-xs uppercase font-bold text-muted-foreground mb-2">Message from Patient</p>
<p className="text-sm italic leading-relaxed whitespace-pre-wrap">
{viewData.message || 'No message provided.'}
</p>
</div>
</div>
</div>
</div>
)}
<DialogFooter>
<Button onClick={() => setViewOpen(false)} className="w-full md:w-auto">
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}