433 lines
18 KiB
TypeScript
433 lines
18 KiB
TypeScript
import type {Route} from "../../../.react-router/types/app/pages/dashboard/+types/PatientsManagement";
|
|
import {useEffect, useMemo, useState} from "react";
|
|
import {DashboardPageType, UserType} from "~/utils/hms_enums.ts";
|
|
import {useNavigate, useOutletContext} from "react-router";
|
|
import {
|
|
type DoctorTeamInfo,
|
|
type OutletContextType,
|
|
type PatientDataWithWardAndAdmission,
|
|
SORT_SYMBOLS,
|
|
type WardInfo
|
|
} from "~/utils/models.ts";
|
|
import {Accordion, ActionIcon, Button, Card, Group, Pagination, Stack, Table, Text, Tooltip} from "@mantine/core";
|
|
import {apiGetPatientsList, apiGetTeamList, apiGetWardList} from "~/utils/hms_api.ts";
|
|
import {showErrorMessage} from "~/utils/utils.ts";
|
|
import {iconMStyle, marginLeftRight, marginRightBottom, marginTopBottom} from "~/styles.ts";
|
|
import PencilIcon from "mdi-react/PencilIcon";
|
|
import LogoutVariantIcon from "mdi-react/LogoutVariantIcon";
|
|
import AddIcon from "mdi-react/AddIcon";
|
|
import InfoIcon from "mdi-react/InfoIcon";
|
|
import {
|
|
confirmAdminAddUser,
|
|
confirmCheckWardPatients,
|
|
confirmEditPatient,
|
|
confirmPatientDischarge,
|
|
confirmResetPassword
|
|
} from "~/components/subs/confirms.tsx";
|
|
import {PatientManagementFilter} from "~/components/subs/PatientManagementFilter.tsx";
|
|
import {PatientInfoDisplay} from "~/components/subs/PatientInfoDisplay.tsx";
|
|
import {TeamInfoDisplay} from "~/components/subs/TeamInfoDisplay.tsx";
|
|
import LockResetIcon from "mdi-react/LockResetIcon";
|
|
import {modals} from "@mantine/modals";
|
|
import HistoryIcon from "mdi-react/HistoryIcon";
|
|
import FilterIcon from "mdi-react/FilterIcon";
|
|
import {ResponsiveTableContainer} from "~/components/subs/ResponsiveTableContainer.tsx";
|
|
|
|
export function meta({}: Route.MetaArgs) {
|
|
return [
|
|
{ title: "Patients Management" },
|
|
{ name: "description", content: "Manage hospital patients" },
|
|
];
|
|
}
|
|
|
|
export default function Component() {
|
|
const navigate = useNavigate();
|
|
const [refreshingPatientList, setRefreshingPatientList] = useState<boolean>(false);
|
|
const [patientInfo, setPatientInfo] = useState<{patients: PatientDataWithWardAndAdmission[], total_pages: number}>({patients: [], total_pages: 1});
|
|
|
|
// Lists for filters
|
|
const [teams, setTeams] = useState<DoctorTeamInfo[]>([]);
|
|
const [wards, setWards] = useState<WardInfo[]>([]);
|
|
const [loadingTeams, setLoadingTeams] = useState(false);
|
|
const [loadingWards, setLoadingWards] = useState(false);
|
|
|
|
const [sortKey, setSortKey] = useState<string>("");
|
|
const [sortDesc, setSortDesc] = useState<boolean>(true);
|
|
const [currPage, setCurrPage] = useState<number>(1);
|
|
|
|
// Filter states
|
|
const [nameSearch, setNameSearch] = useState<string>("");
|
|
const [filterGenders, setFilterGenders] = useState<string[]>([]);
|
|
const [filterHasTeam, setFilterHasTeam] = useState<number>(-1);
|
|
const [filterIsAdmitted, setFilterIsAdmitted] = useState<number>(-1);
|
|
const [filterTeams, setFilterTeams] = useState<number[]>([]);
|
|
const [filterWards, setFilterWards] = useState<number[]>([]);
|
|
|
|
const { changePage } = useOutletContext<OutletContextType>();
|
|
|
|
useEffect(() => {
|
|
refreshPatientList();
|
|
loadTeamsList();
|
|
loadWardsList();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
refreshPatientList();
|
|
}, [currPage]);
|
|
|
|
const loadTeamsList = () => {
|
|
setLoadingTeams(true);
|
|
apiGetTeamList(-1) // Get all teams
|
|
.then(res => {
|
|
if (res.success) {
|
|
setTeams(res.data.teams);
|
|
} else {
|
|
showErrorMessage(res.message, "Failed to load teams");
|
|
}
|
|
})
|
|
.catch(err => {})
|
|
.finally(() => setLoadingTeams(false));
|
|
};
|
|
|
|
const loadWardsList = () => {
|
|
setLoadingWards(true);
|
|
apiGetWardList(-1, true) // Get all wards
|
|
.then(res => {
|
|
if (res.success) {
|
|
setWards(res.data.wards);
|
|
} else {
|
|
showErrorMessage(res.message, "Failed to load wards");
|
|
}
|
|
})
|
|
.catch(err => {})
|
|
.finally(() => setLoadingWards(false));
|
|
};
|
|
|
|
const updateFilter = (
|
|
name: string,
|
|
genders: string[],
|
|
hasTeam: string,
|
|
isAdmitted: string,
|
|
teams: number[],
|
|
wards: number[]
|
|
) => {
|
|
setNameSearch(name);
|
|
setFilterGenders(genders);
|
|
setFilterHasTeam(Number(hasTeam));
|
|
setFilterIsAdmitted(Number(isAdmitted));
|
|
setFilterTeams(teams);
|
|
setFilterWards(wards);
|
|
};
|
|
|
|
const refreshPatientList = () => {
|
|
setRefreshingPatientList(true);
|
|
apiGetPatientsList(currPage).then(res => {
|
|
if (!res.success) {
|
|
showErrorMessage(res.message, "Failed to get patient list");
|
|
return;
|
|
}
|
|
setPatientInfo(res.data);
|
|
})
|
|
.catch(err => {})
|
|
.finally(() => { setRefreshingPatientList(false); });
|
|
};
|
|
|
|
const handleSort = (key: string) => {
|
|
if (sortKey === key) {
|
|
setSortDesc(!sortDesc);
|
|
} else {
|
|
setSortKey(key);
|
|
setSortDesc(false); // Default ascending
|
|
}
|
|
};
|
|
|
|
const sortedPatients = useMemo<PatientDataWithWardAndAdmission[]>(() => {
|
|
let data = [...patientInfo.patients];
|
|
|
|
// Apply all filters
|
|
data = data.filter((p) => {
|
|
// Name search
|
|
const matchesName = !nameSearch || (
|
|
(p.name?.toLowerCase().includes(nameSearch.toLowerCase()) ||
|
|
p.title?.toLowerCase().includes(nameSearch.toLowerCase()))
|
|
);
|
|
|
|
// Gender filter
|
|
const okGender = filterGenders.length === 0 || filterGenders.includes(p.gender);
|
|
|
|
// Team assignment status filter
|
|
const okTeam = filterHasTeam === -1 ||
|
|
(filterHasTeam === 1 ? p.admission?.team_id != null : p.admission?.team_id == null);
|
|
|
|
// Admission status filter
|
|
const okAdmitted = filterIsAdmitted === -1 ||
|
|
(filterIsAdmitted === 1 ? p.admission != null : p.admission == null);
|
|
|
|
// Specific teams filter
|
|
const okSpecificTeams = filterTeams.length === 0 ||
|
|
(p.admission?.team_id != null && filterTeams.includes(p.admission.team_id));
|
|
|
|
// Specific wards filter
|
|
const okSpecificWards = filterWards.length === 0 ||
|
|
(p.ward?.id != null && filterWards.includes(p.ward.id));
|
|
|
|
return matchesName && okGender && okTeam && okAdmitted && okSpecificTeams && okSpecificWards;
|
|
});
|
|
|
|
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;
|
|
}
|
|
|
|
return 0;
|
|
});
|
|
|
|
return data;
|
|
}, [
|
|
patientInfo.patients,
|
|
sortKey,
|
|
sortDesc,
|
|
nameSearch,
|
|
filterGenders,
|
|
filterHasTeam,
|
|
filterIsAdmitted,
|
|
filterTeams,
|
|
filterWards
|
|
]);
|
|
|
|
// Convert teams and wards to options format for MultiSelect
|
|
const teamOptions = useMemo(() => {
|
|
return teams.map(team => ({
|
|
value: team.id.toString(),
|
|
label: team.department.replace(/_/g, ' ')
|
|
}));
|
|
}, [teams]);
|
|
|
|
const wardOptions = useMemo(() => {
|
|
return wards.map(ward => ({
|
|
value: ward.id.toString(),
|
|
label: `${ward.name} (${ward.current_occupancy}/${ward.total_capacity})`
|
|
}));
|
|
}, [wards]);
|
|
|
|
const handleViewPatientInfo = (patient: PatientDataWithWardAndAdmission) => {
|
|
modals.open({
|
|
title: "Patient Information",
|
|
centered: true,
|
|
size: "md",
|
|
children: (
|
|
<Stack>
|
|
<Group>
|
|
<Text fw={500} w={100}>Name:</Text>
|
|
<Text>{`${patient.title} ${patient.name}`}</Text>
|
|
</Group>
|
|
<Group>
|
|
<Text fw={500} w={100}>Gender:</Text>
|
|
<Text>{patient.gender}</Text>
|
|
</Group>
|
|
<Group>
|
|
<Text fw={500} w={100}>Birth Date:</Text>
|
|
<Text>{patient.birth_date}</Text>
|
|
</Group>
|
|
<Group>
|
|
<Text fw={500} w={100}>Email:</Text>
|
|
<Text>{patient.email}</Text>
|
|
</Group>
|
|
<Group>
|
|
<Text fw={500} w={100}>Phone:</Text>
|
|
<Text>{patient.phone}</Text>
|
|
</Group>
|
|
<Group>
|
|
<Text fw={500} w={100}>Address:</Text>
|
|
<Text>{patient.address}</Text>
|
|
</Group>
|
|
<Group>
|
|
<Text fw={500} w={100}>Postcode:</Text>
|
|
<Text>{patient.postcode}</Text>
|
|
</Group>
|
|
|
|
<Group justify="flex-end" mt="md">
|
|
<Button onClick={() => modals.closeAll()}>Close</Button>
|
|
</Group>
|
|
</Stack>
|
|
)
|
|
});
|
|
};
|
|
|
|
const handleEditPatient = (patient: PatientDataWithWardAndAdmission) => {
|
|
confirmEditPatient(patient, refreshPatientList);
|
|
};
|
|
|
|
const handleViewWard = (wardId: number) => {
|
|
confirmCheckWardPatients(wardId, refreshPatientList);
|
|
};
|
|
|
|
const handlePatientDischarge = (admissionId: number, patientName: string) => {
|
|
confirmPatientDischarge(admissionId, patientName, refreshPatientList);
|
|
};
|
|
|
|
const rows = sortedPatients.map((patient) => (
|
|
<Table.Tr key={patient.id}>
|
|
<Table.Td>{patient.id}</Table.Td>
|
|
<Table.Td>
|
|
<PatientInfoDisplay patientId={patient.id} />
|
|
</Table.Td>
|
|
<Table.Td>{patient.gender}</Table.Td>
|
|
<Table.Td>{patient.birth_date}</Table.Td>
|
|
<Table.Td>{patient.email}</Table.Td>
|
|
<Table.Td>{patient.phone}</Table.Td>
|
|
<Table.Td>
|
|
{patient.admission?.team_id ? (
|
|
<TeamInfoDisplay teamId={patient.admission.team_id} />
|
|
) : (
|
|
<Text size="sm" c="dimmed">Not Assigned</Text>
|
|
)}
|
|
</Table.Td>
|
|
<Table.Td>
|
|
{patient.ward ? (
|
|
<Group gap="xs">
|
|
<Text>{patient.ward.name}</Text>
|
|
<ActionIcon
|
|
size="sm"
|
|
onClick={() => handleViewWard(patient.ward!.id)}
|
|
title="View ward details"
|
|
>
|
|
<InfoIcon style={iconMStyle} />
|
|
</ActionIcon>
|
|
</Group>
|
|
) : (
|
|
<Text size="sm" c="dimmed">Not Admitted</Text>
|
|
)}
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<Group>
|
|
<Tooltip label="Treatment Record" withArrow>
|
|
<ActionIcon onClick={() => { changePage(DashboardPageType.TreatmentRecord, `/dashboard/treatment_record/${patient.id}`) }}>
|
|
<HistoryIcon style={iconMStyle}/>
|
|
</ActionIcon>
|
|
</Tooltip>
|
|
<ActionIcon onClick={() => handleViewPatientInfo(patient)} title="View patient info">
|
|
<InfoIcon style={iconMStyle}/>
|
|
</ActionIcon>
|
|
<ActionIcon onClick={() => handleEditPatient(patient)} title="Edit patient info">
|
|
<PencilIcon style={iconMStyle}/>
|
|
</ActionIcon>
|
|
<Tooltip label="Reset Password" withArrow>
|
|
<ActionIcon onClick={() => { confirmResetPassword(patient.id, UserType.PATIENT) }}>
|
|
<LockResetIcon style={iconMStyle}/>
|
|
</ActionIcon>
|
|
</Tooltip>
|
|
<Tooltip label={patient.admission ? "Discharge patient" : "Not admitted"}>
|
|
<ActionIcon
|
|
color="red"
|
|
onClick={() => patient.admission && handlePatientDischarge(patient.admission.id, `${patient.title} ${patient.name}`)}
|
|
disabled={!patient.admission}
|
|
title="Discharge patient"
|
|
>
|
|
<LogoutVariantIcon style={iconMStyle}/>
|
|
</ActionIcon>
|
|
</Tooltip>
|
|
</Group>
|
|
</Table.Td>
|
|
</Table.Tr>
|
|
));
|
|
|
|
return (
|
|
<Stack>
|
|
<Group justify="space-between" align="center" style={marginLeftRight}>
|
|
<Text size="1.5em" fw={700}>Patients Management</Text>
|
|
<Button
|
|
leftSection={<AddIcon style={iconMStyle}/>}
|
|
onClick={() => { confirmAdminAddUser(0, refreshPatientList); }}
|
|
>Add Patient</Button>
|
|
</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>
|
|
<Group>
|
|
<FilterIcon style={iconMStyle} />
|
|
<Text>Filters</Text>
|
|
</Group>
|
|
</Accordion.Control>
|
|
<Accordion.Panel>
|
|
<PatientManagementFilter
|
|
onChange={updateFilter}
|
|
teamOptions={teamOptions}
|
|
wardOptions={wardOptions}
|
|
/>
|
|
</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")}>
|
|
Patient ID{" "}
|
|
{sortKey === "id" && SORT_SYMBOLS[sortDesc ? "desc" : "asc"]}
|
|
</Table.Th>
|
|
<Table.Th onClick={() => handleSort("name")}>
|
|
Name{" "}
|
|
{sortKey === "name" && SORT_SYMBOLS[sortDesc ? "desc" : "asc"]}
|
|
</Table.Th>
|
|
<Table.Th onClick={() => handleSort("gender")}>
|
|
Gender{" "}
|
|
{sortKey === "gender" && SORT_SYMBOLS[sortDesc ? "desc" : "asc"]}
|
|
</Table.Th>
|
|
<Table.Th onClick={() => handleSort("birth_date")}>
|
|
Birth Date{" "}
|
|
{sortKey === "birth_date" && SORT_SYMBOLS[sortDesc ? "desc" : "asc"]}
|
|
</Table.Th>
|
|
<Table.Th onClick={() => handleSort("email")}>
|
|
Email{" "}
|
|
{sortKey === "email" && SORT_SYMBOLS[sortDesc ? "desc" : "asc"]}
|
|
</Table.Th>
|
|
<Table.Th onClick={() => handleSort("phone")}>
|
|
Phone{" "}
|
|
{sortKey === "phone" && SORT_SYMBOLS[sortDesc ? "desc" : "asc"]}
|
|
</Table.Th>
|
|
<Table.Th>
|
|
Medical Team
|
|
</Table.Th>
|
|
<Table.Th>
|
|
Ward
|
|
</Table.Th>
|
|
<Table.Th>
|
|
Operations
|
|
</Table.Th>
|
|
</Table.Tr>
|
|
</Table.Thead>
|
|
<Table.Tbody>{rows}</Table.Tbody>
|
|
</Table>
|
|
</ResponsiveTableContainer>
|
|
|
|
<Pagination
|
|
withEdges
|
|
total={patientInfo.total_pages}
|
|
value={currPage}
|
|
onChange={setCurrPage}
|
|
mt="sm"
|
|
style={{justifyItems: "flex-end", ...marginRightBottom}}
|
|
/>
|
|
</Card.Section>
|
|
</Card>
|
|
</Stack>
|
|
);
|
|
}
|