'use client'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { useSearchParams } from 'next/navigation'; import { useTranslations } from 'next-intl'; import { getUserFacingError } from '@/components/shared/formatApiError'; import { useAuth } from '@/lib/hooks/useAuth'; import { useToast } from '@/lib/hooks/useToast'; import { canEditCases, canEditTasks } from '@/components/shared/permissions'; import { CaseDetailPanel, CaseTaskProgressBar } from '@/components/ui/lab/CaseDetailPanel'; import { LabCaseProsthesisGroupsList } from '@/components/ui/lab/LabCaseProsthesisGroupsList'; import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel'; import { formatCaseDateTime, formatPatientName, } from '@/components/lab/caseDetailUtils'; import { LabCaseDueDateBadge } from '@/components/lab/LabCaseDueDateBadge'; import { notificationsApi } from '@/lib/api/notifications'; import { notifyTabBadgesChanged } from '@/lib/tabBadgeUtils'; import { casesApi } from '@/lib/api/cases'; import { tasksApi } from '@/lib/api/tasks'; import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog'; import { treatmentCatalogApi } from '@/lib/api/treatment-catalog'; import { treatmentTypeLabelFromCatalog } from '@/components/shared/treatmentTypeDisplay'; import { Badge } from '@/components/ui/shared/Badge'; import { Button } from '@/components/ui/shared/Button'; import { MobileDetailBackButton } from '@/components/ui/shared/MobileDetailBackButton'; import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles'; import { SearchBar } from '@/components/ui/shared/SearchBar'; import { AppDateInput } from '@/components/ui/shared/AppDateInput'; import type { ProsthesisCatalogEntry, TreatmentCatalogEntry } from '@/types/treatment-catalog'; import type { AssignableTaskStaff, CasesFilterOptions, LabCaseDetail, LabCaseListItem, LabTaskStatus, PaginatedLabCases, } from '@/types/cases'; const PAGE_SIZE = 20; export function CasesPage() { const t = useTranslations('cases'); const tErrors = useTranslations('errors'); const tCommon = useTranslations('common'); const { currentOrganization, user } = useAuth(); const toast = useToast(); const searchParams = useSearchParams(); const [search, setSearch] = useState(''); const [clinicId, setClinicId] = useState(''); const [prosthesisTypeCode, setProsthesisTypeCode] = useState(''); const [sentFrom, setSentFrom] = useState(''); const [sentTo, setSentTo] = useState(''); const [page, setPage] = useState(1); const [cases, setCases] = useState([]); const [pagination, setPagination] = useState({ page: 1, limit: PAGE_SIZE, total: 0, totalPages: 1, }); const [filterOptions, setFilterOptions] = useState({ clinics: [], prosthesisTypes: [], }); const [prosthesisCatalog, setProsthesisCatalog] = useState([]); const [treatmentCatalog, setTreatmentCatalog] = useState([]); const [selectedCaseId, setSelectedCaseId] = useState(null); const [mobileDetailOpen, setMobileDetailOpen] = useState(false); const [selectedCase, setSelectedCase] = useState(null); const [loadingList, setLoadingList] = useState(false); const [loadingDetail, setLoadingDetail] = useState(false); const [updatingImportant, setUpdatingImportant] = useState(false); const [assignableStaff, setAssignableStaff] = useState([]); const [assigningTaskId, setAssigningTaskId] = useState(null); const [commentCount, setCommentCount] = useState(0); const canEdit = canEditCases(currentOrganization); const canEditComments = canEditTasks(currentOrganization); const locale = user?.language ?? 'en'; const prosthesisLabel = useCallback( (code: string) => prosthesisCatalog.find((entry) => entry.code === code)?.label ?? code, [prosthesisCatalog], ); const treatmentDetailLabel = useCallback( (type: string) => treatmentTypeLabelFromCatalog(type, treatmentCatalog), [treatmentCatalog], ); const statusOptions: { value: LabTaskStatus; label: string }[] = useMemo( () => [ { value: 'IN_PROGRESS', label: t('statusInProgress') }, { value: 'COMPLETED', label: t('statusCompleted') }, ], [t], ); const hasActiveFilters = Boolean( search.trim() || clinicId || prosthesisTypeCode || sentFrom || sentTo, ); const loadCases = async (params: { q: string; clinicOrganizationId: string; prosthesisTypeCode: string; sentFrom: string; sentTo: string; page: number; }) => { setLoadingList(true); toast.setError(''); try { const response = await casesApi.list({ q: params.q.trim() || undefined, clinicOrganizationId: params.clinicOrganizationId || undefined, prosthesisTypeCode: params.prosthesisTypeCode || undefined, sentFrom: params.sentFrom || undefined, sentTo: params.sentTo || undefined, page: params.page, limit: PAGE_SIZE, }); setCases(response.data.items); setPagination(response.data.pagination); } catch (error: unknown) { toast.showError(getUserFacingError(error, tErrors, t('errorLoadList'))); } finally { setLoadingList(false); } }; const loadDetail = async (caseId: string, options?: { silent?: boolean }) => { if (!options?.silent) { setLoadingDetail(true); } toast.setError(''); try { const response = await casesApi.getOne(caseId); setSelectedCase(response.data); } catch (error: unknown) { toast.showError(getUserFacingError(error, tErrors, t('errorLoadDetail'))); if (!options?.silent) { setSelectedCase(null); } } finally { if (!options?.silent) { setLoadingDetail(false); } } }; useEffect(() => { void casesApi.listFilterOptions().then((r) => setFilterOptions(r.data)).catch(() => {}); void prosthesisCatalogApi.list().then((r) => setProsthesisCatalog(r.data)).catch(() => {}); void treatmentCatalogApi.list().then((r) => setTreatmentCatalog(r.data)).catch(() => {}); if (canEdit) { void casesApi.listAssignableStaff().then((r) => setAssignableStaff(r.data)).catch(() => {}); } // eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only initial fetch }, [canEdit]); useEffect(() => { const clinicFromUrl = searchParams.get('clinicOrganizationId'); if (clinicFromUrl) { setClinicId(clinicFromUrl); } }, [searchParams]); useEffect(() => { if (loadingList) return; if (cases.length === 0) { if (selectedCaseId !== null) { setSelectedCaseId(null); } return; } const urlCaseId = searchParams.get('caseId'); if (urlCaseId && cases.some((item) => item.id === urlCaseId)) { if (selectedCaseId !== urlCaseId) { setSelectedCaseId(urlCaseId); setMobileDetailOpen(true); } return; } if (selectedCaseId && cases.some((item) => item.id === selectedCaseId)) { return; } setSelectedCaseId(cases[0].id); }, [cases, loadingList, searchParams, selectedCaseId]); useEffect(() => { const timeout = setTimeout(() => { void loadCases({ q: search, clinicOrganizationId: clinicId, prosthesisTypeCode, sentFrom, sentTo, page, }); }, search ? 300 : 0); return () => clearTimeout(timeout); // eslint-disable-next-line react-hooks/exhaustive-deps -- debounced search + filter reload }, [search, clinicId, prosthesisTypeCode, sentFrom, sentTo, page]); useEffect(() => { if (!selectedCaseId) { setMobileDetailOpen(false); } }, [selectedCaseId]); useEffect(() => { if (selectedCaseId) { void loadDetail(selectedCaseId); void notificationsApi.markCaseRead(selectedCaseId).then(() => { notifyTabBadgesChanged(); setCases((prev) => prev.map((item) => item.id === selectedCaseId ? { ...item, hasUnread: false } : item, ), ); }); void tasksApi .listComments(selectedCaseId) .then((r) => setCommentCount(r.data.length)) .catch(() => setCommentCount(0)); } else { setSelectedCase(null); setCommentCount(0); } // eslint-disable-next-line react-hooks/exhaustive-deps -- reload when selection changes }, [selectedCaseId]); function scrollToComments() { document.getElementById('case-comments')?.scrollIntoView({ behavior: 'smooth' }); } const loadCaseAttachmentBlob = useCallback( (caseId: string, attachmentId: string) => casesApi.getAttachmentFileBlob(caseId, attachmentId), [], ); function clearFilters() { setSearch(''); setClinicId(''); setProsthesisTypeCode(''); setSentFrom(''); setSentTo(''); setPage(1); } async function handleAssignTask(taskId: string, assigneeUserId: string | null) { if (!selectedCaseId || !canEdit) return; setAssigningTaskId(taskId); toast.setError(''); try { const response = await casesApi.assignTask(selectedCaseId, taskId, assigneeUserId); setSelectedCase(response.data); } catch (error: unknown) { toast.showError(getUserFacingError(error, tErrors, t('errorAssignTask'))); } finally { setAssigningTaskId(null); } } async function handleCaseImportantToggle(isImportant: boolean) { if (!selectedCaseId || !canEdit || !selectedCase) return; const previousCase = selectedCase; setSelectedCase({ ...selectedCase, isImportant }); setUpdatingImportant(true); toast.setError(''); try { const response = await casesApi.setCaseImportant(selectedCaseId, isImportant); setSelectedCase(response.data); setCases((prev) => prev.map((item) => item.id === selectedCaseId ? { ...item, isImportant: response.data.isImportant } : item, ), ); notifyTabBadgesChanged(); } catch (error: unknown) { setSelectedCase(previousCase); toast.showError(getUserFacingError(error, tErrors, t('errorUpdateTask'))); } finally { setUpdatingImportant(false); } } const filterSelectClass = `${FORM_SELECT_CLASS} w-full min-w-0 rounded-md py-1.5 text-xs sm:py-2 sm:text-sm`; return (

{t('title')}

{t('subtitle')}

{ setSearch(value); setPage(1); }} placeholder={t('searchPlaceholder')} />
{hasActiveFilters ? ( ) : null}
{loadingList ? (

{tCommon('loading')}

) : cases.length === 0 ? (

{t('emptyList')}

) : (
    {cases.map((item) => { const isActive = item.id === selectedCaseId; return (
  • ); })}
)}
{pagination.totalPages > 1 ? (
{t('pageSummary', { page: pagination.page, totalPages: pagination.totalPages, total: pagination.total, })}
) : null}
{mobileDetailOpen && selectedCaseId ? ( setMobileDetailOpen(false)} /> ) : null} {!selectedCaseId && !loadingList && cases.length === 0 ? (

{t('emptyList')}

) : loadingDetail || !selectedCase ? (

{tCommon('loading')}

) : ( void handleCaseImportantToggle(checked)} assignableStaff={assignableStaff} canAssignTasks={canEdit} assigningTaskId={assigningTaskId} onAssignTask={(taskId, assigneeUserId) => void handleAssignTask(taskId, assigneeUserId) } headerMetaLines={

{t('fromClinic', { name: selectedCase.clinic.name })}

} commentsSection={ selectedCaseId ? (
{ const r = await tasksApi.listComments(selectedCaseId); setCommentCount(r.data.length); return r.data; }} onPost={async (body, visibleToClinic) => { const r = await tasksApi.addComment(selectedCaseId, { body, visibleToClinic, }); setCommentCount((n) => n + 1); notifyTabBadgesChanged(); return r.data; }} onToggleVisibility={async (commentId, visible) => { const r = await tasksApi.setCommentVisibility(commentId, visible); return r.data; }} onError={toast.showError} />
) : null } /> )}
); }