416 lines
17 KiB
TypeScript
416 lines
17 KiB
TypeScript
import type {Route} from "../../../.react-router/types/app/pages/dashboard/+types/AppointmentManagement";
|
|
import {useDisclosure} from "@mantine/hooks";
|
|
import {useEffect, useMemo, useState} from "react";
|
|
import {BookingCategory, DashboardPageType} from "~/utils/hms_enums.ts";
|
|
import {useNavigate, useOutletContext} from "react-router";
|
|
import {
|
|
type OutletContextType,
|
|
type PatientBookingInfo,
|
|
type DoctorTeamInfo,
|
|
SORT_SYMBOLS
|
|
} from "~/utils/models.ts";
|
|
import {Accordion, ActionIcon, Badge, Button, Card, Group, Pagination, Stack, Table, Text} from "@mantine/core";
|
|
import {apiGetAllAppointments, get_team_info} from "~/utils/hms_api.ts";
|
|
import {showErrorMessage, timestampToDate} from "~/utils/utils.ts";
|
|
import {iconMStyle, marginLeftRight, marginRightBottom, marginRound, marginTopBottom, textCenter} from "~/styles.ts";
|
|
import CheckCircleIcon from "mdi-react/CheckCircleIcon";
|
|
import CancelIcon from "mdi-react/CancelIcon";
|
|
import HospitalIcon from "mdi-react/HospitalIcon";
|
|
import {
|
|
confirmApproveAppointment,
|
|
confirmRejectAppointment,
|
|
confirmPatientAdmission,
|
|
confirmViewTeamMembers
|
|
} from "~/components/subs/confirms.tsx";
|
|
import {AppointmentManageFilter} from "~/components/subs/AppointmentManageFilter.tsx";
|
|
import HomePlusIcon from "mdi-react/HomePlusIcon";
|
|
import {TruncatedText} from "~/components/subs/TruncatedText.tsx";
|
|
import InfoIcon from "mdi-react/InfoIcon";
|
|
import {PatientInfoDisplay} from "~/components/subs/PatientInfoDisplay.tsx";
|
|
import {ResponsiveTableContainer} from "~/components/subs/ResponsiveTableContainer.tsx";
|
|
|
|
export function meta({}: Route.MetaArgs) {
|
|
return [
|
|
{ title: "Appointment Management" },
|
|
{ name: "description", content: "Appointment Management" },
|
|
];
|
|
}
|
|
|
|
export default function Component() {
|
|
const navigate = useNavigate();
|
|
const [refreshingAppointmentList, setRefreshingAppointmentList] = useState<boolean>(false);
|
|
const [appointmentInfo, setAppointmentInfo] = useState<{appointments: PatientBookingInfo[], total_pages: number}>({appointments: [], total_pages: 1});
|
|
const [teamInfoMap, setTeamInfoMap] = useState<Map<number, DoctorTeamInfo>>(new Map());
|
|
const [loadingTeams, setLoadingTeams] = useState<Set<number>>(new Set());
|
|
|
|
const [sortKey, setSortKey] = useState<string>("id");
|
|
const [sortDesc, setSortDesc] = useState<boolean>(true);
|
|
const [currPage, setCurrPage] = useState<number>(1);
|
|
|
|
const [filterCategories, setFilterCategories] = useState<string[]>([]);
|
|
const [filterStatus, setFilterStatus] = useState<number>(-1);
|
|
const [includeDischarged, setIncludeDischarged] = useState<boolean>(false);
|
|
|
|
const { loginUserInfo, refreshMyInfo } = useOutletContext<OutletContextType>();
|
|
|
|
useEffect(() => {
|
|
refreshAppointmentList();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
refreshAppointmentList();
|
|
}, [currPage, includeDischarged]);
|
|
|
|
const updateFilter = (categories: string[], status: number, discharged: boolean) => {
|
|
setFilterCategories(categories);
|
|
setFilterStatus(status);
|
|
setIncludeDischarged(discharged);
|
|
};
|
|
|
|
const refreshAppointmentList = () => {
|
|
setRefreshingAppointmentList(true);
|
|
apiGetAllAppointments(currPage, includeDischarged).then(res => {
|
|
if (!res.success) {
|
|
showErrorMessage(res.message, "Failed to get appointments list");
|
|
return;
|
|
}
|
|
setAppointmentInfo(res.data);
|
|
|
|
// Fetch team info for all appointments with assigned teams
|
|
const teamsToFetch = res.data.appointments
|
|
.filter(app => app.assigned_team !== null)
|
|
.map(app => app.assigned_team as number);
|
|
|
|
// Remove duplicates
|
|
const uniqueTeams = [...new Set(teamsToFetch)];
|
|
|
|
// Fetch team info for each unique team ID
|
|
uniqueTeams.forEach(teamId => {
|
|
fetchTeamInfo(teamId);
|
|
});
|
|
})
|
|
.catch(err => {})
|
|
.finally(() => { setRefreshingAppointmentList(false); });
|
|
};
|
|
|
|
const fetchTeamInfo = (teamId: number) => {
|
|
if (teamId === null || teamInfoMap.has(teamId) || loadingTeams.has(teamId)) {
|
|
return;
|
|
}
|
|
|
|
setLoadingTeams(prev => new Set([...prev, teamId]));
|
|
|
|
get_team_info(teamId).then(res => {
|
|
if (res.success) {
|
|
setTeamInfoMap(prev => {
|
|
const newMap = new Map(prev);
|
|
newMap.set(teamId, res.data.team);
|
|
return newMap;
|
|
});
|
|
}
|
|
})
|
|
.catch(err => {})
|
|
.finally(() => {
|
|
setLoadingTeams(prev => {
|
|
const newSet = new Set(prev);
|
|
newSet.delete(teamId);
|
|
return newSet;
|
|
});
|
|
});
|
|
};
|
|
|
|
const handleViewTeam = (teamId: number) => {
|
|
const teamInfo = teamInfoMap.get(teamId);
|
|
if (teamInfo) {
|
|
confirmViewTeamMembers(teamInfo);
|
|
} else {
|
|
// If team info is not fetched yet, fetch it and then show
|
|
get_team_info(teamId).then(res => {
|
|
if (res.success) {
|
|
setTeamInfoMap(prev => {
|
|
const newMap = new Map(prev);
|
|
newMap.set(teamId, res.data.team);
|
|
return newMap;
|
|
});
|
|
confirmViewTeamMembers(res.data.team);
|
|
} else {
|
|
showErrorMessage(res.message, "Failed to fetch team information");
|
|
}
|
|
}).catch(err => {
|
|
showErrorMessage("Failed to fetch team information", "Error");
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleSort = (key: string) => {
|
|
if (sortKey === key) {
|
|
setSortDesc(!sortDesc);
|
|
} else {
|
|
setSortKey(key);
|
|
setSortDesc(false); // Default ascending
|
|
}
|
|
};
|
|
|
|
const sortedAppointments = useMemo(() => {
|
|
let data = [...appointmentInfo.appointments];
|
|
|
|
// Apply filters
|
|
data = data.filter((appointment) => {
|
|
// Filter by category
|
|
const categoryMatch = filterCategories.length === 0 || filterCategories.includes(appointment.category);
|
|
|
|
// Filter by status
|
|
let statusMatch = true;
|
|
if (filterStatus !== -1) {
|
|
if (filterStatus === 0) { // Pending
|
|
statusMatch = !appointment.approved;
|
|
} else if (filterStatus === 1) { // Approved
|
|
statusMatch = appointment.approved && appointment.assigned_team !== null;
|
|
} else if (filterStatus === 2) { // Rejected
|
|
statusMatch = appointment.approved && appointment.assigned_team === null;
|
|
}
|
|
}
|
|
|
|
return categoryMatch && statusMatch;
|
|
});
|
|
|
|
// Apply sorting
|
|
if (!sortKey) return data;
|
|
|
|
data.sort((a, b) => {
|
|
const valA = a[sortKey];
|
|
const valB = b[sortKey];
|
|
|
|
if (typeof valA === 'string' && typeof valB === 'string') {
|
|
const cmp = valA.localeCompare(valB);
|
|
return sortDesc ? -cmp : cmp;
|
|
}
|
|
|
|
if (typeof valA === 'number' && typeof valB === 'number') {
|
|
return sortDesc ? valB - valA : valA - valB;
|
|
}
|
|
|
|
if (typeof valA === 'boolean' && typeof valB === 'boolean') {
|
|
return sortDesc ? (valB ? 1 : -1) : (valA ? 1 : -1);
|
|
}
|
|
|
|
return 0;
|
|
});
|
|
|
|
return data;
|
|
}, [appointmentInfo.appointments, sortKey, sortDesc, filterCategories, filterStatus]);
|
|
|
|
const handleApproveAppointment = (appointmentId: number) => {
|
|
confirmApproveAppointment(appointmentId, refreshAppointmentList);
|
|
};
|
|
|
|
const handleRejectAppointment = (appointmentId: number) => {
|
|
confirmRejectAppointment(appointmentId, refreshAppointmentList);
|
|
};
|
|
|
|
const handlePatientAdmission = (appointmentId: number) => {
|
|
confirmPatientAdmission(appointmentId, refreshAppointmentList);
|
|
};
|
|
|
|
const getCategoryDisplay = (category: string) => {
|
|
const key = Object.entries(BookingCategory).find(([_, value]) => value === category)?.[0];
|
|
return key ? key.replace(/_/g, ' ') : category;
|
|
};
|
|
|
|
const getStatusBadge = (appointment: PatientBookingInfo) => {
|
|
if (appointment.discharged) {
|
|
return <Badge color="purple">Discharged</Badge>;
|
|
} else if (appointment.admitted) {
|
|
return <Badge color="blue">Admitted</Badge>;
|
|
} else if (appointment.approved) {
|
|
if (appointment.assigned_team === null) {
|
|
return <Badge color="red">Rejected</Badge>;
|
|
} else {
|
|
return <Badge color="green">Approved</Badge>;
|
|
}
|
|
} else {
|
|
return <Badge color="yellow">Pending</Badge>;
|
|
}
|
|
};
|
|
|
|
const getTeamDisplay = (appointment: PatientBookingInfo) => {
|
|
if (!appointment.approved) {
|
|
return <Text size="sm" c="dimmed">Pending approval</Text>;
|
|
}
|
|
|
|
if (appointment.assigned_team === null) {
|
|
return <Text size="sm" c="dimmed">Not assigned</Text>;
|
|
}
|
|
|
|
const teamInfo = teamInfoMap.get(appointment.assigned_team);
|
|
if (!teamInfo) {
|
|
return (
|
|
<Group gap="xs">
|
|
<Text>ID: {appointment.assigned_team}</Text>
|
|
<ActionIcon
|
|
size="sm"
|
|
onClick={() => handleViewTeam(appointment.assigned_team as number)}
|
|
title="View team details"
|
|
>
|
|
<InfoIcon style={iconMStyle} />
|
|
</ActionIcon>
|
|
</Group>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Group gap="xs">
|
|
<Text>{teamInfo.department.replace(/_/g, ' ')}</Text>
|
|
<ActionIcon
|
|
size="sm"
|
|
onClick={() => handleViewTeam(appointment.assigned_team as number)}
|
|
title="View team details"
|
|
>
|
|
<InfoIcon style={iconMStyle} />
|
|
</ActionIcon>
|
|
</Group>
|
|
);
|
|
};
|
|
|
|
const rows = sortedAppointments.map((appointment) => (
|
|
<Table.Tr key={appointment.id}>
|
|
<Table.Td>{appointment.id}</Table.Td>
|
|
<Table.Td>
|
|
<PatientInfoDisplay patientId={appointment.patient_id} />
|
|
</Table.Td>
|
|
<Table.Td>{getCategoryDisplay(appointment.category)}</Table.Td>
|
|
<Table.Td>{new Date(appointment.appointment_time * 1000).toLocaleString('en-GB').replace(',', '')}</Table.Td>
|
|
<Table.Td>
|
|
<TruncatedText
|
|
text={appointment.description}
|
|
title={`Description - Appointment #${appointment.id}`}
|
|
/>
|
|
</Table.Td>
|
|
<Table.Td>{getStatusBadge(appointment)}</Table.Td>
|
|
<Table.Td>
|
|
{appointment.feedback ?
|
|
<TruncatedText
|
|
text={appointment.feedback}
|
|
title={`Feedback - Appointment #${appointment.id}`}
|
|
/> :
|
|
'-'
|
|
}
|
|
</Table.Td>
|
|
<Table.Td>{getTeamDisplay(appointment)}</Table.Td>
|
|
<Table.Td>
|
|
<Group>
|
|
<ActionIcon
|
|
color="green"
|
|
onClick={() => handleApproveAppointment(appointment.id)}
|
|
title="Approve"
|
|
disabled={appointment.admitted || appointment.discharged}
|
|
>
|
|
<CheckCircleIcon style={iconMStyle}/>
|
|
</ActionIcon>
|
|
<ActionIcon
|
|
color="red"
|
|
onClick={() => handleRejectAppointment(appointment.id)}
|
|
title="Reject"
|
|
disabled={appointment.admitted || appointment.discharged}
|
|
>
|
|
<CancelIcon style={iconMStyle}/>
|
|
</ActionIcon>
|
|
<ActionIcon
|
|
color={appointment.approved && appointment.assigned_team !== null && !appointment.admitted && !appointment.discharged ? "blue" : "gray"}
|
|
onClick={() => {
|
|
if (appointment.approved && appointment.assigned_team !== null && !appointment.admitted && !appointment.discharged) {
|
|
handlePatientAdmission(appointment.id);
|
|
}
|
|
}}
|
|
disabled={!appointment.approved || appointment.assigned_team === null || appointment.admitted || appointment.discharged}
|
|
title={
|
|
appointment.admitted ? "Already admitted" :
|
|
appointment.discharged ? "Already discharged" :
|
|
!appointment.approved ? "Not approved yet" :
|
|
appointment.assigned_team === null ? "No team assigned" :
|
|
"Admit Patient"
|
|
}
|
|
>
|
|
<HomePlusIcon style={iconMStyle}/>
|
|
</ActionIcon>
|
|
|
|
</Group>
|
|
</Table.Td>
|
|
</Table.Tr>
|
|
));
|
|
|
|
return (
|
|
<Stack>
|
|
<Group justify="space-between" align="center" style={marginLeftRight}>
|
|
<Text size="1.5em" fw={700}>Appointment Management</Text>
|
|
</Group>
|
|
|
|
<Card padding="lg" radius="md" withBorder style={marginTopBottom}>
|
|
<Card.Section withBorder>
|
|
<Accordion variant="filled" chevronPosition="left" defaultValue="advanced-filter">
|
|
<Accordion.Item value="advanced-filter">
|
|
<Accordion.Control>Filter</Accordion.Control>
|
|
<Accordion.Panel>
|
|
<AppointmentManageFilter onChange={updateFilter}/>
|
|
</Accordion.Panel>
|
|
</Accordion.Item>
|
|
</Accordion>
|
|
</Card.Section>
|
|
|
|
<Card.Section>
|
|
<ResponsiveTableContainer minWidth={600}>
|
|
<Table striped highlightOnHover withColumnBorders withTableBorder>
|
|
<Table.Thead>
|
|
<Table.Tr>
|
|
<Table.Th onClick={() => handleSort("id")}>
|
|
ID{" "}
|
|
{sortKey === "id" && SORT_SYMBOLS[sortDesc ? "desc" : "asc"]}
|
|
</Table.Th>
|
|
<Table.Th onClick={() => handleSort("patient_id")}>
|
|
Patient ID{" "}
|
|
{sortKey === "patient_id" && SORT_SYMBOLS[sortDesc ? "desc" : "asc"]}
|
|
</Table.Th>
|
|
<Table.Th onClick={() => handleSort("category")}>
|
|
Category{" "}
|
|
{sortKey === "category" && SORT_SYMBOLS[sortDesc ? "desc" : "asc"]}
|
|
</Table.Th>
|
|
<Table.Th onClick={() => handleSort("appointment_time")}>
|
|
Appointment Time{" "}
|
|
{sortKey === "appointment_time" && SORT_SYMBOLS[sortDesc ? "desc" : "asc"]}
|
|
</Table.Th>
|
|
<Table.Th>
|
|
Description
|
|
</Table.Th>
|
|
<Table.Th onClick={() => handleSort("approved")}>
|
|
Status{" "}
|
|
{sortKey === "approved" && SORT_SYMBOLS[sortDesc ? "desc" : "asc"]}
|
|
</Table.Th>
|
|
<Table.Th>
|
|
Feedback
|
|
</Table.Th>
|
|
<Table.Th onClick={() => handleSort("assigned_team")}>
|
|
Team{" "}
|
|
{sortKey === "assigned_team" && SORT_SYMBOLS[sortDesc ? "desc" : "asc"]}
|
|
</Table.Th>
|
|
<Table.Th>
|
|
Actions
|
|
</Table.Th>
|
|
</Table.Tr>
|
|
</Table.Thead>
|
|
<Table.Tbody>{rows}</Table.Tbody>
|
|
</Table>
|
|
</ResponsiveTableContainer>
|
|
|
|
<Pagination
|
|
withEdges
|
|
total={appointmentInfo.total_pages}
|
|
value={currPage}
|
|
onChange={setCurrPage}
|
|
mt="sm"
|
|
style={{justifyItems: "flex-end", ...marginRightBottom}}
|
|
/>
|
|
</Card.Section>
|
|
</Card>
|
|
</Stack>
|
|
);
|
|
}
|