feature: Phase5 - Lab Cases inbox + task board

This commit is contained in:
2026-06-28 17:49:40 +03:30
parent 7b19d6953c
commit 478cfa085a
9 changed files with 431 additions and 83 deletions

View File

@@ -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) {

View File

@@ -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<string, { id: string; name: string }>();
const typeCodes = new Set<string>();
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[] = [
{

View File

@@ -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()

View File

@@ -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",

View File

@@ -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": "نوبت‌ها",

View File

@@ -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",

View File

@@ -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 (
<div className="space-y-1">
<div className="flex items-center justify-between text-xs text-text-muted">
<span>{completed}/{total}</span>
<span>{pct}%</span>
</div>
<div className="h-1.5 rounded-full bg-border overflow-hidden">
<div
className="h-full rounded-full bg-primary transition-all duration-300"
style={{ width: `${pct}%` }}
/>
</div>
</div>
);
}
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<LabCaseListItem[]>([]);
const [pagination, setPagination] = useState<PaginatedLabCases['pagination']>({
page: 1,
limit: PAGE_SIZE,
total: 0,
totalPages: 1,
});
const [filterOptions, setFilterOptions] = useState<CasesFilterOptions>({
clinics: [],
treatmentTypes: [],
});
const [selectedCaseId, setSelectedCaseId] = useState<string | null>(null);
const [selectedCase, setSelectedCase] = useState<LabCaseDetail | null>(null);
const [members, setMembers] = useState<AssignableMember[]>([]);
@@ -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 (
<div className="space-y-4">
<div>
@@ -142,57 +235,165 @@ export default function CasesPage() {
<p className="text-sm text-text-muted mt-1">{t('subtitle')}</p>
</div>
<div className="grid gap-4 lg:grid-cols-[minmax(280px,360px)_1fr]">
<section className="rounded-lg border border-border bg-surface p-4 space-y-3">
<input
type="search"
<div className="grid gap-4 lg:grid-cols-[minmax(300px,380px)_1fr]">
<section className="rounded-lg border border-border bg-surface p-4 space-y-3 flex flex-col min-h-0">
<SearchBar
embedded
value={search}
onChange={(e) => 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 ? (
<p className="text-sm text-text-muted">{tCommon('loading')}</p>
) : cases.length === 0 ? (
<p className="text-sm text-text-muted">{t('emptyList')}</p>
) : (
<ul className="space-y-2 max-h-[70vh] overflow-y-auto">
{cases.map((item) => {
const isActive = item.id === selectedCaseId;
const progress =
item.taskProgress.total > 0
? `${item.taskProgress.completed}/${item.taskProgress.total}`
: '0/0';
<div className="grid gap-2 sm:grid-cols-2">
<label className="space-y-1">
<span className="text-xs font-medium text-text-muted">{t('filterClinic')}</span>
<select
value={clinicId}
onChange={(e) => {
setClinicId(e.target.value);
setPage(1);
}}
className={filterSelectClass}
>
<option value="">{t('filterClinicAll')}</option>
{filterOptions.clinics.map((clinic) => (
<option key={clinic.id} value={clinic.id}>
{clinic.name}
</option>
))}
</select>
</label>
return (
<li key={item.id}>
<button
type="button"
onClick={() => setSelectedCaseId(item.id)}
className={`w-full rounded-md border px-3 py-2 text-left transition-colors ${
isActive
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary/40'
}`}
>
<div className="font-medium text-text-primary">
{formatPatientName(item.patient)}
</div>
<div className="text-xs text-text-muted mt-0.5">{item.clinic.name}</div>
<div className="flex items-center justify-between text-xs text-text-muted mt-1">
<span>{formatDateTime(item.sentAt, locale)}</span>
<span>{t('taskProgressShort', { progress })}</span>
</div>
<div className="text-xs text-text-muted mt-1 truncate">
{item.treatmentTypes.map(treatmentLabel).join(', ')}
</div>
</button>
</li>
);
})}
</ul>
)}
<label className="space-y-1">
<span className="text-xs font-medium text-text-muted">{t('filterTreatmentType')}</span>
<select
value={treatmentType}
onChange={(e) => {
setTreatmentType(e.target.value);
setPage(1);
}}
className={filterSelectClass}
>
<option value="">{t('filterTreatmentTypeAll')}</option>
{filterOptions.treatmentTypes.map((type) => (
<option key={type.code} value={type.code}>
{treatmentLabel(type.code)}
</option>
))}
</select>
</label>
<label className="space-y-1">
<span className="text-xs font-medium text-text-muted">{t('filterSentFrom')}</span>
<input
type="date"
value={sentFrom}
onChange={(e) => {
setSentFrom(e.target.value);
setPage(1);
}}
className={filterSelectClass}
/>
</label>
<label className="space-y-1">
<span className="text-xs font-medium text-text-muted">{t('filterSentTo')}</span>
<input
type="date"
value={sentTo}
onChange={(e) => {
setSentTo(e.target.value);
setPage(1);
}}
className={filterSelectClass}
/>
</label>
</div>
{hasActiveFilters ? (
<Button variant="ghost" size="sm" onClick={clearFilters} className="self-start">
{t('clearFilters')}
</Button>
) : null}
<div className="flex-1 min-h-0">
{loadingList ? (
<p className="text-sm text-text-muted">{tCommon('loading')}</p>
) : cases.length === 0 ? (
<p className="text-sm text-text-muted">{t('emptyList')}</p>
) : (
<ul className="space-y-2 max-h-[55vh] overflow-y-auto pr-1">
{cases.map((item) => {
const isActive = item.id === selectedCaseId;
return (
<li key={item.id}>
<button
type="button"
onClick={() => setSelectedCaseId(item.id)}
className={`w-full rounded-md border px-3 py-2.5 text-left transition-colors ${
isActive
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary/40'
}`}
>
<div className="font-medium text-text-primary">
{formatPatientName(item.patient)}
</div>
<div className="text-xs text-text-muted mt-0.5">
{item.patient.mobile}
</div>
<div className="text-xs text-text-muted mt-0.5">{item.clinic.name}</div>
<div className="text-xs text-text-muted mt-1">
{formatDateTime(item.sentAt, locale)}
</div>
<div className="text-xs text-text-muted mt-1 truncate">
{item.treatmentTypes.map(treatmentLabel).join(', ')}
</div>
<div className="mt-2">
<TaskProgressBar
completed={item.taskProgress.completed}
total={item.taskProgress.total}
/>
</div>
</button>
</li>
);
})}
</ul>
)}
</div>
{pagination.totalPages > 1 ? (
<div className="flex items-center justify-between gap-2 pt-2 border-t border-border">
<Button
variant="outline"
size="sm"
disabled={page <= 1 || loadingList}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
{t('prevPage')}
</Button>
<span className="text-xs text-text-muted text-center">
{t('pageSummary', {
page: pagination.page,
totalPages: pagination.totalPages,
total: pagination.total,
})}
</span>
<Button
variant="outline"
size="sm"
disabled={page >= pagination.totalPages || loadingList}
onClick={() => setPage((p) => p + 1)}
>
{t('nextPage')}
</Button>
</div>
) : null}
</section>
<section className="rounded-lg border border-border bg-surface p-4 min-h-[420px]">
@@ -206,18 +407,33 @@ export default function CasesPage() {
<h2 className="text-lg font-semibold text-text-primary">
{formatPatientName(selectedCase.patient)}
</h2>
<p className="text-sm text-text-muted">
{t('patientMobile')}: {selectedCase.patient.mobile}
</p>
<p className="text-sm text-text-muted">
{t('fromClinic', { name: selectedCase.clinic.name })}
</p>
<p className="text-sm text-text-muted">
{t('sentAt', { date: formatDateTime(selectedCase.sentAt, locale) })}
</p>
<p className="text-sm text-text-muted">
{t('taskProgressLabel', {
completed: selectedCase.taskProgress.completed,
total: selectedCase.taskProgress.total,
})}
</p>
<div className="pt-1 max-w-xs">
<p className="text-sm text-text-muted mb-1">
{t('taskProgressLabel', {
completed: selectedCase.taskProgress.completed,
total: selectedCase.taskProgress.total,
})}
</p>
<TaskProgressBar
completed={selectedCase.taskProgress.completed}
total={selectedCase.taskProgress.total}
/>
</div>
{selectedCase.labComment ? (
<p className="text-sm text-text-muted pt-1">
<span className="font-medium text-text-primary">{t('labComment')}:</span>{' '}
{selectedCase.labComment}
</p>
) : null}
</header>
{selectedCase.details.length > 0 && (

View File

@@ -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,

View File

@@ -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 {