From 478cfa085ad8be1cb0b9cccf3c4312ff2e9190a7 Mon Sep 17 00:00:00 2001 From: Admin Date: Sun, 28 Jun 2026 17:49:40 +0330 Subject: [PATCH] feature: Phase5 - Lab Cases inbox + task board --- backend/src/modules/cases/cases.controller.ts | 7 + backend/src/modules/cases/cases.service.ts | 102 +++++- backend/src/modules/cases/dto/cases.dto.ts | 12 +- frontend/messages/en.json | 14 +- frontend/messages/fa.json | 14 +- frontend/messages/nl.json | 14 +- .../app/[locale]/(dashboard)/cases/page.tsx | 338 ++++++++++++++---- frontend/src/lib/api/cases.ts | 6 + frontend/src/types/cases.ts | 7 + 9 files changed, 431 insertions(+), 83 deletions(-) diff --git a/backend/src/modules/cases/cases.controller.ts b/backend/src/modules/cases/cases.controller.ts index 2216e85..7c3a967 100644 --- a/backend/src/modules/cases/cases.controller.ts +++ b/backend/src/modules/cases/cases.controller.ts @@ -28,6 +28,13 @@ export class CasesController { return this.casesService.list(organizationId, req.user.id, query); } + @Get('filter-options') + @ApiOperation({ summary: 'Clinics and treatment types for inbox filters' }) + listFilterOptions(@Req() req) { + const organizationId = this.casesService.getOrganizationIdFromUser(req.user); + return this.casesService.listFilterOptions(organizationId, req.user.id); + } + @Get('assignable-members') @ApiOperation({ summary: 'List lab staff who can be assigned to tasks' }) listAssignableMembers(@Req() req) { diff --git a/backend/src/modules/cases/cases.service.ts b/backend/src/modules/cases/cases.service.ts index 924ed55..2ea727a 100644 --- a/backend/src/modules/cases/cases.service.ts +++ b/backend/src/modules/cases/cases.service.ts @@ -72,23 +72,7 @@ export class CasesService { const limit = Math.min(Math.max(query.limit ?? 20, 1), 100); const skip = (page - 1) * limit; - const where: Prisma.LabCaseWhereInput = { - sentAt: { not: null }, - sends: { some: { organizationId: labOrganizationId } }, - ...(query.clinicOrganizationId - ? { treatment: { organizationId: query.clinicOrganizationId } } - : {}), - ...(query.treatmentType - ? { - details: { - some: { detail: { treatmentType: query.treatmentType } }, - }, - } - : {}), - ...(query.q?.trim() - ? this.buildSearchWhere(query.q.trim()) - : {}), - }; + const where = this.buildListWhere(labOrganizationId, query); const [items, total] = await Promise.all([ this.prisma.labCase.findMany({ @@ -128,6 +112,50 @@ export class CasesService { }; } + async listFilterOptions(labOrganizationId: string, actorUserId: string) { + await this.assertCanReadCases(actorUserId, labOrganizationId); + + const rows = await this.prisma.labCase.findMany({ + where: { + sentAt: { not: null }, + sends: { some: { organizationId: labOrganizationId } }, + }, + select: { + treatment: { + select: { + organization: { select: { id: true, name: true } }, + }, + }, + details: { + select: { detail: { select: { treatmentType: true } } }, + }, + }, + }); + + const clinicsById = new Map(); + const typeCodes = new Set(); + + for (const row of rows) { + clinicsById.set(row.treatment.organization.id, row.treatment.organization); + for (const link of row.details) { + typeCodes.add(link.detail.treatmentType); + } + } + + const treatmentTypes = this.treatmentCatalog + .list() + .filter((entry) => entry.labDependent && typeCodes.has(entry.code)) + .map((entry) => ({ code: entry.code, labDependent: entry.labDependent })); + + return { + success: true, + data: { + clinics: [...clinicsById.values()].sort((a, b) => a.name.localeCompare(b.name)), + treatmentTypes, + }, + }; + } + async getOne(labCaseId: string, labOrganizationId: string, actorUserId: string) { await this.assertCanReadCases(actorUserId, labOrganizationId); @@ -209,6 +237,46 @@ export class CasesService { }; } + private buildListWhere( + labOrganizationId: string, + query: ListLabCasesDto, + ): Prisma.LabCaseWhereInput { + const sentAtFilter: Prisma.DateTimeNullableFilter = { not: null }; + + if (query.sentFrom) { + const from = new Date(query.sentFrom); + if (Number.isNaN(from.getTime())) { + throw new BadRequestException('Invalid sentFrom date'); + } + sentAtFilter.gte = from; + } + + if (query.sentTo) { + const to = new Date(query.sentTo); + if (Number.isNaN(to.getTime())) { + throw new BadRequestException('Invalid sentTo date'); + } + to.setHours(23, 59, 59, 999); + sentAtFilter.lte = to; + } + + return { + sentAt: sentAtFilter, + sends: { some: { organizationId: labOrganizationId } }, + ...(query.clinicOrganizationId + ? { treatment: { organizationId: query.clinicOrganizationId } } + : {}), + ...(query.treatmentType + ? { + details: { + some: { detail: { treatmentType: query.treatmentType } }, + }, + } + : {}), + ...(query.q?.trim() ? this.buildSearchWhere(query.q.trim()) : {}), + }; + } + private buildSearchWhere(q: string): Prisma.LabCaseWhereInput { const orConditions: Prisma.LabCaseWhereInput[] = [ { diff --git a/backend/src/modules/cases/dto/cases.dto.ts b/backend/src/modules/cases/dto/cases.dto.ts index 331b798..438da73 100644 --- a/backend/src/modules/cases/dto/cases.dto.ts +++ b/backend/src/modules/cases/dto/cases.dto.ts @@ -1,5 +1,5 @@ import { Transform } from 'class-transformer'; -import { IsEnum, IsInt, IsOptional, IsString, IsUUID, Max, Min, ValidateIf } from 'class-validator'; +import { IsDateString, IsEnum, IsInt, IsOptional, IsString, IsUUID, Max, Min, ValidateIf } from 'class-validator'; import { LabTaskStatus } from '@prisma/client'; export class UpdateLabCaseTaskDto { @@ -19,13 +19,21 @@ export class ListLabCasesDto { q?: string; @IsOptional() - @IsString() + @IsUUID() clinicOrganizationId?: string; @IsOptional() @IsString() treatmentType?: string; + @IsOptional() + @IsDateString() + sentFrom?: string; + + @IsOptional() + @IsDateString() + sentTo?: string; + @IsOptional() @Transform(({ value }) => Number(value)) @IsInt() diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 3f889c3..72695a5 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -336,7 +336,19 @@ "statusCompleted": "Completed", "errorLoadList": "Failed to load cases.", "errorLoadDetail": "Failed to load case details.", - "errorUpdateTask": "Failed to update task." + "errorUpdateTask": "Failed to update task.", + "filterClinic": "Clinic", + "filterClinicAll": "All clinics", + "filterTreatmentType": "Treatment type", + "filterTreatmentTypeAll": "All types", + "filterSentFrom": "Sent from", + "filterSentTo": "Sent to", + "clearFilters": "Clear filters", + "patientMobile": "Mobile", + "labComment": "Lab comment", + "prevPage": "Previous", + "nextPage": "Next", + "pageSummary": "Page {page} of {totalPages} ({total} cases)" }, "appointments": { "title": "Appointments", diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index c8394cb..6c0560f 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -336,7 +336,19 @@ "statusCompleted": "انجام شده", "errorLoadList": "بارگذاری پرونده‌ها ناموفق بود.", "errorLoadDetail": "بارگذاری جزئیات پرونده ناموفق بود.", - "errorUpdateTask": "به‌روزرسانی وظیفه ناموفق بود." + "errorUpdateTask": "به‌روزرسانی وظیفه ناموفق بود.", + "filterClinic": "کلینیک", + "filterClinicAll": "همه کلینیک‌ها", + "filterTreatmentType": "نوع درمان", + "filterTreatmentTypeAll": "همه انواع", + "filterSentFrom": "ارسال از", + "filterSentTo": "ارسال تا", + "clearFilters": "پاک کردن فیلترها", + "patientMobile": "موبایل", + "labComment": "یادداشت آزمایشگاه", + "prevPage": "قبلی", + "nextPage": "بعدی", + "pageSummary": "صفحه {page} از {totalPages} ({total} پرونده)" }, "appointments": { "title": "نوبت‌ها", diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index 16ea5b7..c22dbfb 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -336,7 +336,19 @@ "statusCompleted": "Voltooid", "errorLoadList": "Dossiers laden mislukt.", "errorLoadDetail": "Dossierdetails laden mislukt.", - "errorUpdateTask": "Taak bijwerken mislukt." + "errorUpdateTask": "Taak bijwerken mislukt.", + "filterClinic": "Kliniek", + "filterClinicAll": "Alle klinieken", + "filterTreatmentType": "Behandeltype", + "filterTreatmentTypeAll": "Alle types", + "filterSentFrom": "Verzonden vanaf", + "filterSentTo": "Verzonden tot", + "clearFilters": "Filters wissen", + "patientMobile": "Mobiel", + "labComment": "Labnotitie", + "prevPage": "Vorige", + "nextPage": "Volgende", + "pageSummary": "Pagina {page} van {totalPages} ({total} dossiers)" }, "appointments": { "title": "Afspraken", diff --git a/frontend/src/app/[locale]/(dashboard)/cases/page.tsx b/frontend/src/app/[locale]/(dashboard)/cases/page.tsx index 49657df..207b4c4 100644 --- a/frontend/src/app/[locale]/(dashboard)/cases/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/cases/page.tsx @@ -8,7 +8,16 @@ import { useAuth } from '@/lib/hooks/useAuth'; import { useToast } from '@/lib/hooks/useToast'; import { hasPermission } from '@/components/shared/permissions'; import { casesApi } from '@/lib/api/cases'; -import type { AssignableMember, LabCaseDetail, LabCaseListItem, LabTaskStatus } from '@/types/cases'; +import { Button } from '@/components/ui/shared/Button'; +import { SearchBar } from '@/components/ui/shared/SearchBar'; +import type { + AssignableMember, + CasesFilterOptions, + LabCaseDetail, + LabCaseListItem, + LabTaskStatus, + PaginatedLabCases, +} from '@/types/cases'; const TREATMENT_TYPE_KEYS = { consultation: 'typeConsultation', @@ -18,6 +27,8 @@ const TREATMENT_TYPE_KEYS = { hygiene: 'typeHygiene', } as const; +const PAGE_SIZE = 20; + function formatPatientName(patient: { firstName: string; lastName: string }) { return `${patient.firstName} ${patient.lastName}`.trim(); } @@ -30,6 +41,25 @@ function formatDateTime(value: string | null, locale: string) { }).format(new Date(value)); } +function TaskProgressBar({ completed, total }: { completed: number; total: number }) { + const pct = total > 0 ? Math.round((completed / total) * 100) : 0; + + return ( +
+
+ {completed}/{total} + {pct}% +
+
+
+
+
+ ); +} + export default function CasesPage() { const t = useTranslations('cases'); const tTreatment = useTranslations('treatment'); @@ -38,7 +68,24 @@ export default function CasesPage() { const toast = useToast(); const [search, setSearch] = useState(''); + const [clinicId, setClinicId] = useState(''); + const [treatmentType, setTreatmentType] = 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: [], + treatmentTypes: [], + }); + const [selectedCaseId, setSelectedCaseId] = useState(null); const [selectedCase, setSelectedCase] = useState(null); const [members, setMembers] = useState([]); @@ -66,12 +113,32 @@ export default function CasesPage() { [t], ); - const loadCases = async (q: string) => { + const hasActiveFilters = Boolean( + search.trim() || clinicId || treatmentType || sentFrom || sentTo, + ); + + const loadCases = async (params: { + q: string; + clinicOrganizationId: string; + treatmentType: string; + sentFrom: string; + sentTo: string; + page: number; + }) => { setLoadingList(true); toast.setError(''); try { - const response = await casesApi.list({ q: q.trim() || undefined, page: 1, limit: 50 }); + const response = await casesApi.list({ + q: params.q.trim() || undefined, + clinicOrganizationId: params.clinicOrganizationId || undefined, + treatmentType: params.treatmentType || 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(formatApiErrorMessage(error, t('errorLoadList'))); } finally { @@ -94,18 +161,25 @@ export default function CasesPage() { }; useEffect(() => { - void loadCases(''); + void casesApi.listFilterOptions().then((r) => setFilterOptions(r.data)).catch(() => {}); void casesApi.listAssignableMembers().then((r) => setMembers(r.data)).catch(() => {}); // eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only initial fetch }, []); useEffect(() => { const timeout = setTimeout(() => { - void loadCases(search); - }, 300); + void loadCases({ + q: search, + clinicOrganizationId: clinicId, + treatmentType, + sentFrom, + sentTo, + page, + }); + }, search ? 300 : 0); return () => clearTimeout(timeout); - // eslint-disable-next-line react-hooks/exhaustive-deps -- debounced search only - }, [search]); + // eslint-disable-next-line react-hooks/exhaustive-deps -- debounced search + filter reload + }, [search, clinicId, treatmentType, sentFrom, sentTo, page]); useEffect(() => { if (selectedCaseId) { @@ -116,6 +190,15 @@ export default function CasesPage() { // eslint-disable-next-line react-hooks/exhaustive-deps -- reload when selection changes }, [selectedCaseId]); + function clearFilters() { + setSearch(''); + setClinicId(''); + setTreatmentType(''); + setSentFrom(''); + setSentTo(''); + setPage(1); + } + async function handleTaskUpdate( taskId: string, payload: { assigneeUserId?: string | null; status?: LabTaskStatus }, @@ -127,7 +210,14 @@ export default function CasesPage() { try { await casesApi.updateTask(selectedCaseId, taskId, payload); await loadDetail(selectedCaseId); - await loadCases(search); + await loadCases({ + q: search, + clinicOrganizationId: clinicId, + treatmentType, + sentFrom, + sentTo, + page, + }); } catch (error: unknown) { toast.showError(formatApiErrorMessage(error, t('errorUpdateTask'))); } finally { @@ -135,6 +225,9 @@ export default function CasesPage() { } } + const filterSelectClass = + 'w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-text-primary'; + return (
@@ -142,57 +235,165 @@ export default function CasesPage() {

{t('subtitle')}

-
-
- +
+ setSearch(e.target.value)} + onChange={(value) => { + setSearch(value); + setPage(1); + }} placeholder={t('searchPlaceholder')} - className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm" /> - {loadingList ? ( -

{tCommon('loading')}

- ) : cases.length === 0 ? ( -

{t('emptyList')}

- ) : ( -
    - {cases.map((item) => { - const isActive = item.id === selectedCaseId; - const progress = - item.taskProgress.total > 0 - ? `${item.taskProgress.completed}/${item.taskProgress.total}` - : '0/0'; +
    + - return ( -
  • - -
  • - ); - })} -
- )} + + + + + +
+ + {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}
@@ -206,18 +407,33 @@ export default function CasesPage() {

{formatPatientName(selectedCase.patient)}

+

+ {t('patientMobile')}: {selectedCase.patient.mobile} +

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

{t('sentAt', { date: formatDateTime(selectedCase.sentAt, locale) })}

-

- {t('taskProgressLabel', { - completed: selectedCase.taskProgress.completed, - total: selectedCase.taskProgress.total, - })} -

+
+

+ {t('taskProgressLabel', { + completed: selectedCase.taskProgress.completed, + total: selectedCase.taskProgress.total, + })} +

+ +
+ {selectedCase.labComment ? ( +

+ {t('labComment')}:{' '} + {selectedCase.labComment} +

+ ) : null} {selectedCase.details.length > 0 && ( diff --git a/frontend/src/lib/api/cases.ts b/frontend/src/lib/api/cases.ts index fd6f1da..da7496b 100644 --- a/frontend/src/lib/api/cases.ts +++ b/frontend/src/lib/api/cases.ts @@ -1,6 +1,7 @@ import { apiClient } from './client'; import type { AssignableMember, + CasesFilterOptions, LabCaseDetail, LabCaseTask, ListLabCasesParams, @@ -25,6 +26,11 @@ export const casesApi = { return response.data; }, + listFilterOptions: async (): Promise<{ success: boolean; data: CasesFilterOptions }> => { + const response = await apiClient.get('/cases/filter-options'); + return response.data; + }, + updateTask: async ( caseId: string, taskId: string, diff --git a/frontend/src/types/cases.ts b/frontend/src/types/cases.ts index fed5383..bd06ded 100644 --- a/frontend/src/types/cases.ts +++ b/frontend/src/types/cases.ts @@ -73,6 +73,13 @@ export interface ListLabCasesParams { limit?: number; clinicOrganizationId?: string; treatmentType?: string; + sentFrom?: string; + sentTo?: string; +} + +export interface CasesFilterOptions { + clinics: Array<{ id: string; name: string }>; + treatmentTypes: Array<{ code: string; labDependent: boolean }>; } export interface PaginatedLabCases {