feature: Phase5 - Lab Cases inbox + task board
This commit is contained in:
@@ -28,6 +28,13 @@ export class CasesController {
|
|||||||
return this.casesService.list(organizationId, req.user.id, query);
|
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')
|
@Get('assignable-members')
|
||||||
@ApiOperation({ summary: 'List lab staff who can be assigned to tasks' })
|
@ApiOperation({ summary: 'List lab staff who can be assigned to tasks' })
|
||||||
listAssignableMembers(@Req() req) {
|
listAssignableMembers(@Req() req) {
|
||||||
|
|||||||
@@ -72,23 +72,7 @@ export class CasesService {
|
|||||||
const limit = Math.min(Math.max(query.limit ?? 20, 1), 100);
|
const limit = Math.min(Math.max(query.limit ?? 20, 1), 100);
|
||||||
const skip = (page - 1) * limit;
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
const where: Prisma.LabCaseWhereInput = {
|
const where = this.buildListWhere(labOrganizationId, query);
|
||||||
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 [items, total] = await Promise.all([
|
const [items, total] = await Promise.all([
|
||||||
this.prisma.labCase.findMany({
|
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) {
|
async getOne(labCaseId: string, labOrganizationId: string, actorUserId: string) {
|
||||||
await this.assertCanReadCases(actorUserId, labOrganizationId);
|
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 {
|
private buildSearchWhere(q: string): Prisma.LabCaseWhereInput {
|
||||||
const orConditions: Prisma.LabCaseWhereInput[] = [
|
const orConditions: Prisma.LabCaseWhereInput[] = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Transform } from 'class-transformer';
|
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';
|
import { LabTaskStatus } from '@prisma/client';
|
||||||
|
|
||||||
export class UpdateLabCaseTaskDto {
|
export class UpdateLabCaseTaskDto {
|
||||||
@@ -19,13 +19,21 @@ export class ListLabCasesDto {
|
|||||||
q?: string;
|
q?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsUUID()
|
||||||
clinicOrganizationId?: string;
|
clinicOrganizationId?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
treatmentType?: string;
|
treatmentType?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
sentFrom?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
sentTo?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Transform(({ value }) => Number(value))
|
@Transform(({ value }) => Number(value))
|
||||||
@IsInt()
|
@IsInt()
|
||||||
|
|||||||
@@ -336,7 +336,19 @@
|
|||||||
"statusCompleted": "Completed",
|
"statusCompleted": "Completed",
|
||||||
"errorLoadList": "Failed to load cases.",
|
"errorLoadList": "Failed to load cases.",
|
||||||
"errorLoadDetail": "Failed to load case details.",
|
"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": {
|
"appointments": {
|
||||||
"title": "Appointments",
|
"title": "Appointments",
|
||||||
|
|||||||
@@ -336,7 +336,19 @@
|
|||||||
"statusCompleted": "انجام شده",
|
"statusCompleted": "انجام شده",
|
||||||
"errorLoadList": "بارگذاری پروندهها ناموفق بود.",
|
"errorLoadList": "بارگذاری پروندهها ناموفق بود.",
|
||||||
"errorLoadDetail": "بارگذاری جزئیات پرونده ناموفق بود.",
|
"errorLoadDetail": "بارگذاری جزئیات پرونده ناموفق بود.",
|
||||||
"errorUpdateTask": "بهروزرسانی وظیفه ناموفق بود."
|
"errorUpdateTask": "بهروزرسانی وظیفه ناموفق بود.",
|
||||||
|
"filterClinic": "کلینیک",
|
||||||
|
"filterClinicAll": "همه کلینیکها",
|
||||||
|
"filterTreatmentType": "نوع درمان",
|
||||||
|
"filterTreatmentTypeAll": "همه انواع",
|
||||||
|
"filterSentFrom": "ارسال از",
|
||||||
|
"filterSentTo": "ارسال تا",
|
||||||
|
"clearFilters": "پاک کردن فیلترها",
|
||||||
|
"patientMobile": "موبایل",
|
||||||
|
"labComment": "یادداشت آزمایشگاه",
|
||||||
|
"prevPage": "قبلی",
|
||||||
|
"nextPage": "بعدی",
|
||||||
|
"pageSummary": "صفحه {page} از {totalPages} ({total} پرونده)"
|
||||||
},
|
},
|
||||||
"appointments": {
|
"appointments": {
|
||||||
"title": "نوبتها",
|
"title": "نوبتها",
|
||||||
|
|||||||
@@ -336,7 +336,19 @@
|
|||||||
"statusCompleted": "Voltooid",
|
"statusCompleted": "Voltooid",
|
||||||
"errorLoadList": "Dossiers laden mislukt.",
|
"errorLoadList": "Dossiers laden mislukt.",
|
||||||
"errorLoadDetail": "Dossierdetails 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": {
|
"appointments": {
|
||||||
"title": "Afspraken",
|
"title": "Afspraken",
|
||||||
|
|||||||
@@ -8,7 +8,16 @@ import { useAuth } from '@/lib/hooks/useAuth';
|
|||||||
import { useToast } from '@/lib/hooks/useToast';
|
import { useToast } from '@/lib/hooks/useToast';
|
||||||
import { hasPermission } from '@/components/shared/permissions';
|
import { hasPermission } from '@/components/shared/permissions';
|
||||||
import { casesApi } from '@/lib/api/cases';
|
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 = {
|
const TREATMENT_TYPE_KEYS = {
|
||||||
consultation: 'typeConsultation',
|
consultation: 'typeConsultation',
|
||||||
@@ -18,6 +27,8 @@ const TREATMENT_TYPE_KEYS = {
|
|||||||
hygiene: 'typeHygiene',
|
hygiene: 'typeHygiene',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
function formatPatientName(patient: { firstName: string; lastName: string }) {
|
function formatPatientName(patient: { firstName: string; lastName: string }) {
|
||||||
return `${patient.firstName} ${patient.lastName}`.trim();
|
return `${patient.firstName} ${patient.lastName}`.trim();
|
||||||
}
|
}
|
||||||
@@ -30,6 +41,25 @@ function formatDateTime(value: string | null, locale: string) {
|
|||||||
}).format(new Date(value));
|
}).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() {
|
export default function CasesPage() {
|
||||||
const t = useTranslations('cases');
|
const t = useTranslations('cases');
|
||||||
const tTreatment = useTranslations('treatment');
|
const tTreatment = useTranslations('treatment');
|
||||||
@@ -38,7 +68,24 @@ export default function CasesPage() {
|
|||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
const [search, setSearch] = useState('');
|
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 [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 [selectedCaseId, setSelectedCaseId] = useState<string | null>(null);
|
||||||
const [selectedCase, setSelectedCase] = useState<LabCaseDetail | null>(null);
|
const [selectedCase, setSelectedCase] = useState<LabCaseDetail | null>(null);
|
||||||
const [members, setMembers] = useState<AssignableMember[]>([]);
|
const [members, setMembers] = useState<AssignableMember[]>([]);
|
||||||
@@ -66,12 +113,32 @@ export default function CasesPage() {
|
|||||||
[t],
|
[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);
|
setLoadingList(true);
|
||||||
toast.setError('');
|
toast.setError('');
|
||||||
try {
|
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);
|
setCases(response.data.items);
|
||||||
|
setPagination(response.data.pagination);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
toast.showError(formatApiErrorMessage(error, t('errorLoadList')));
|
toast.showError(formatApiErrorMessage(error, t('errorLoadList')));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -94,18 +161,25 @@ export default function CasesPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadCases('');
|
void casesApi.listFilterOptions().then((r) => setFilterOptions(r.data)).catch(() => {});
|
||||||
void casesApi.listAssignableMembers().then((r) => setMembers(r.data)).catch(() => {});
|
void casesApi.listAssignableMembers().then((r) => setMembers(r.data)).catch(() => {});
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only initial fetch
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only initial fetch
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
void loadCases(search);
|
void loadCases({
|
||||||
}, 300);
|
q: search,
|
||||||
|
clinicOrganizationId: clinicId,
|
||||||
|
treatmentType,
|
||||||
|
sentFrom,
|
||||||
|
sentTo,
|
||||||
|
page,
|
||||||
|
});
|
||||||
|
}, search ? 300 : 0);
|
||||||
return () => clearTimeout(timeout);
|
return () => clearTimeout(timeout);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- debounced search only
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- debounced search + filter reload
|
||||||
}, [search]);
|
}, [search, clinicId, treatmentType, sentFrom, sentTo, page]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedCaseId) {
|
if (selectedCaseId) {
|
||||||
@@ -116,6 +190,15 @@ export default function CasesPage() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- reload when selection changes
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- reload when selection changes
|
||||||
}, [selectedCaseId]);
|
}, [selectedCaseId]);
|
||||||
|
|
||||||
|
function clearFilters() {
|
||||||
|
setSearch('');
|
||||||
|
setClinicId('');
|
||||||
|
setTreatmentType('');
|
||||||
|
setSentFrom('');
|
||||||
|
setSentTo('');
|
||||||
|
setPage(1);
|
||||||
|
}
|
||||||
|
|
||||||
async function handleTaskUpdate(
|
async function handleTaskUpdate(
|
||||||
taskId: string,
|
taskId: string,
|
||||||
payload: { assigneeUserId?: string | null; status?: LabTaskStatus },
|
payload: { assigneeUserId?: string | null; status?: LabTaskStatus },
|
||||||
@@ -127,7 +210,14 @@ export default function CasesPage() {
|
|||||||
try {
|
try {
|
||||||
await casesApi.updateTask(selectedCaseId, taskId, payload);
|
await casesApi.updateTask(selectedCaseId, taskId, payload);
|
||||||
await loadDetail(selectedCaseId);
|
await loadDetail(selectedCaseId);
|
||||||
await loadCases(search);
|
await loadCases({
|
||||||
|
q: search,
|
||||||
|
clinicOrganizationId: clinicId,
|
||||||
|
treatmentType,
|
||||||
|
sentFrom,
|
||||||
|
sentTo,
|
||||||
|
page,
|
||||||
|
});
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
toast.showError(formatApiErrorMessage(error, t('errorUpdateTask')));
|
toast.showError(formatApiErrorMessage(error, t('errorUpdateTask')));
|
||||||
} finally {
|
} 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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -142,57 +235,165 @@ export default function CasesPage() {
|
|||||||
<p className="text-sm text-text-muted mt-1">{t('subtitle')}</p>
|
<p className="text-sm text-text-muted mt-1">{t('subtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 lg:grid-cols-[minmax(280px,360px)_1fr]">
|
<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">
|
<section className="rounded-lg border border-border bg-surface p-4 space-y-3 flex flex-col min-h-0">
|
||||||
<input
|
<SearchBar
|
||||||
type="search"
|
embedded
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(value) => {
|
||||||
|
setSearch(value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
placeholder={t('searchPlaceholder')}
|
placeholder={t('searchPlaceholder')}
|
||||||
className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{loadingList ? (
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
<p className="text-sm text-text-muted">{tCommon('loading')}</p>
|
<label className="space-y-1">
|
||||||
) : cases.length === 0 ? (
|
<span className="text-xs font-medium text-text-muted">{t('filterClinic')}</span>
|
||||||
<p className="text-sm text-text-muted">{t('emptyList')}</p>
|
<select
|
||||||
) : (
|
value={clinicId}
|
||||||
<ul className="space-y-2 max-h-[70vh] overflow-y-auto">
|
onChange={(e) => {
|
||||||
{cases.map((item) => {
|
setClinicId(e.target.value);
|
||||||
const isActive = item.id === selectedCaseId;
|
setPage(1);
|
||||||
const progress =
|
}}
|
||||||
item.taskProgress.total > 0
|
className={filterSelectClass}
|
||||||
? `${item.taskProgress.completed}/${item.taskProgress.total}`
|
>
|
||||||
: '0/0';
|
<option value="">{t('filterClinicAll')}</option>
|
||||||
|
{filterOptions.clinics.map((clinic) => (
|
||||||
|
<option key={clinic.id} value={clinic.id}>
|
||||||
|
{clinic.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
return (
|
<label className="space-y-1">
|
||||||
<li key={item.id}>
|
<span className="text-xs font-medium text-text-muted">{t('filterTreatmentType')}</span>
|
||||||
<button
|
<select
|
||||||
type="button"
|
value={treatmentType}
|
||||||
onClick={() => setSelectedCaseId(item.id)}
|
onChange={(e) => {
|
||||||
className={`w-full rounded-md border px-3 py-2 text-left transition-colors ${
|
setTreatmentType(e.target.value);
|
||||||
isActive
|
setPage(1);
|
||||||
? 'border-primary bg-primary/5'
|
}}
|
||||||
: 'border-border hover:border-primary/40'
|
className={filterSelectClass}
|
||||||
}`}
|
>
|
||||||
>
|
<option value="">{t('filterTreatmentTypeAll')}</option>
|
||||||
<div className="font-medium text-text-primary">
|
{filterOptions.treatmentTypes.map((type) => (
|
||||||
{formatPatientName(item.patient)}
|
<option key={type.code} value={type.code}>
|
||||||
</div>
|
{treatmentLabel(type.code)}
|
||||||
<div className="text-xs text-text-muted mt-0.5">{item.clinic.name}</div>
|
</option>
|
||||||
<div className="flex items-center justify-between text-xs text-text-muted mt-1">
|
))}
|
||||||
<span>{formatDateTime(item.sentAt, locale)}</span>
|
</select>
|
||||||
<span>{t('taskProgressShort', { progress })}</span>
|
</label>
|
||||||
</div>
|
|
||||||
<div className="text-xs text-text-muted mt-1 truncate">
|
<label className="space-y-1">
|
||||||
{item.treatmentTypes.map(treatmentLabel).join(', ')}
|
<span className="text-xs font-medium text-text-muted">{t('filterSentFrom')}</span>
|
||||||
</div>
|
<input
|
||||||
</button>
|
type="date"
|
||||||
</li>
|
value={sentFrom}
|
||||||
);
|
onChange={(e) => {
|
||||||
})}
|
setSentFrom(e.target.value);
|
||||||
</ul>
|
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>
|
||||||
|
|
||||||
<section className="rounded-lg border border-border bg-surface p-4 min-h-[420px]">
|
<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">
|
<h2 className="text-lg font-semibold text-text-primary">
|
||||||
{formatPatientName(selectedCase.patient)}
|
{formatPatientName(selectedCase.patient)}
|
||||||
</h2>
|
</h2>
|
||||||
|
<p className="text-sm text-text-muted">
|
||||||
|
{t('patientMobile')}: {selectedCase.patient.mobile}
|
||||||
|
</p>
|
||||||
<p className="text-sm text-text-muted">
|
<p className="text-sm text-text-muted">
|
||||||
{t('fromClinic', { name: selectedCase.clinic.name })}
|
{t('fromClinic', { name: selectedCase.clinic.name })}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-text-muted">
|
<p className="text-sm text-text-muted">
|
||||||
{t('sentAt', { date: formatDateTime(selectedCase.sentAt, locale) })}
|
{t('sentAt', { date: formatDateTime(selectedCase.sentAt, locale) })}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-text-muted">
|
<div className="pt-1 max-w-xs">
|
||||||
{t('taskProgressLabel', {
|
<p className="text-sm text-text-muted mb-1">
|
||||||
completed: selectedCase.taskProgress.completed,
|
{t('taskProgressLabel', {
|
||||||
total: selectedCase.taskProgress.total,
|
completed: selectedCase.taskProgress.completed,
|
||||||
})}
|
total: selectedCase.taskProgress.total,
|
||||||
</p>
|
})}
|
||||||
|
</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>
|
</header>
|
||||||
|
|
||||||
{selectedCase.details.length > 0 && (
|
{selectedCase.details.length > 0 && (
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { apiClient } from './client';
|
import { apiClient } from './client';
|
||||||
import type {
|
import type {
|
||||||
AssignableMember,
|
AssignableMember,
|
||||||
|
CasesFilterOptions,
|
||||||
LabCaseDetail,
|
LabCaseDetail,
|
||||||
LabCaseTask,
|
LabCaseTask,
|
||||||
ListLabCasesParams,
|
ListLabCasesParams,
|
||||||
@@ -25,6 +26,11 @@ export const casesApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
listFilterOptions: async (): Promise<{ success: boolean; data: CasesFilterOptions }> => {
|
||||||
|
const response = await apiClient.get('/cases/filter-options');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
updateTask: async (
|
updateTask: async (
|
||||||
caseId: string,
|
caseId: string,
|
||||||
taskId: string,
|
taskId: string,
|
||||||
|
|||||||
@@ -73,6 +73,13 @@ export interface ListLabCasesParams {
|
|||||||
limit?: number;
|
limit?: number;
|
||||||
clinicOrganizationId?: string;
|
clinicOrganizationId?: string;
|
||||||
treatmentType?: 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 {
|
export interface PaginatedLabCases {
|
||||||
|
|||||||
Reference in New Issue
Block a user