From 3e81c110a3682262b69f7451e353190c26c35460 Mon Sep 17 00:00:00 2001 From: Admin Date: Sun, 28 Jun 2026 23:19:33 +0330 Subject: [PATCH] improvement: added a history action button to connected orgs row inordr to see a brief report of the relevant cases between two orgs and the status of each related task. --- backend/src/modules/cases/cases.module.ts | 3 + backend/src/modules/cases/cases.service.ts | 80 ++++ .../organization/organization.controller.ts | 35 ++ .../organization/organization.module.ts | 2 + .../organization/organization.service.ts | 117 ++++- frontend/messages/en.json | 11 +- frontend/messages/fa.json | 11 +- frontend/messages/nl.json | 11 +- .../(dashboard)/organizations/page.tsx | 46 +- .../ConnectionCaseHistoryContent.tsx | 407 ++++++++++++++++++ frontend/src/lib/api/organization.ts | 31 ++ 11 files changed, 739 insertions(+), 15 deletions(-) create mode 100644 frontend/src/components/ui/organizations/ConnectionCaseHistoryContent.tsx diff --git a/backend/src/modules/cases/cases.module.ts b/backend/src/modules/cases/cases.module.ts index 6957773..6bd4a71 100644 --- a/backend/src/modules/cases/cases.module.ts +++ b/backend/src/modules/cases/cases.module.ts @@ -1,11 +1,14 @@ import { Module } from '@nestjs/common'; import { PrismaService } from '../../../prisma/prisma.service'; import { LabOrgGuard } from '../../common/guards/lab-org.guard'; +import { TreatmentCatalogModule } from '../treatment-catalog/treatment-catalog.module'; import { CasesController } from './cases.controller'; import { CasesService } from './cases.service'; @Module({ + imports: [TreatmentCatalogModule], controllers: [CasesController], providers: [CasesService, PrismaService, LabOrgGuard], + exports: [CasesService], }) export class CasesModule {} diff --git a/backend/src/modules/cases/cases.service.ts b/backend/src/modules/cases/cases.service.ts index 940ccc9..401e4d9 100644 --- a/backend/src/modules/cases/cases.service.ts +++ b/backend/src/modules/cases/cases.service.ts @@ -156,6 +156,86 @@ export class CasesService { }; } + /** Cases exchanged between one clinic and one lab (Organizations connection history). */ + async listBetweenOrganizations( + clinicOrganizationId: string, + labOrganizationId: string, + query: ListLabCasesDto, + ) { + if (query.treatmentType) { + this.treatmentCatalog.assertKnownTreatmentType(query.treatmentType); + } + + const page = query.page ?? 1; + const limit = Math.min(Math.max(query.limit ?? 20, 1), 100); + const skip = (page - 1) * limit; + + const where: Prisma.LabCaseWhereInput = { + ...this.buildListWhere(labOrganizationId, query), + treatment: { organizationId: clinicOrganizationId }, + sends: { some: { organizationId: labOrganizationId } }, + }; + + const [items, total] = await Promise.all([ + this.prisma.labCase.findMany({ + where, + include: { + treatment: { + include: { + organization: { select: { id: true, name: true } }, + patient: { select: { id: true, firstName: true, lastName: true, mobile: true } }, + }, + }, + details: { + include: { + detail: { select: { treatmentType: true } }, + }, + }, + tasks: { select: { id: true, status: true } }, + }, + orderBy: [{ sentAt: 'desc' }], + skip, + take: limit, + }), + this.prisma.labCase.count({ where }), + ]); + + return { + success: true, + data: { + items: items.map((lc) => this.mapLabCaseListItem(lc)), + pagination: { + page, + limit, + total, + totalPages: Math.max(1, Math.ceil(total / limit)), + }, + }, + }; + } + + async getOneBetweenOrganizations( + labCaseId: string, + clinicOrganizationId: string, + labOrganizationId: string, + ) { + const labCase = await this.prisma.labCase.findFirst({ + where: { + id: labCaseId, + sentAt: { not: null }, + treatment: { organizationId: clinicOrganizationId }, + sends: { some: { organizationId: labOrganizationId } }, + }, + include: labCaseListInclude, + }); + + if (!labCase) { + throw new NotFoundException('Case not found'); + } + + return { success: true, data: this.mapLabCaseDetail(labCase) }; + } + async getOne(labCaseId: string, labOrganizationId: string, actorUserId: string) { await this.assertCanReadCases(actorUserId, labOrganizationId); diff --git a/backend/src/modules/organization/organization.controller.ts b/backend/src/modules/organization/organization.controller.ts index 7f0fc68..90db4e4 100644 --- a/backend/src/modules/organization/organization.controller.ts +++ b/backend/src/modules/organization/organization.controller.ts @@ -18,6 +18,7 @@ import { InviteOrganizationDto } from './dto/invite-organization.dto'; import { PreviewOrganizationInviteDto } from './dto/preview-organization-invite.dto'; import { RespondConnectionRequestDto } from './dto/respond-connection-request.dto'; import { OrganizationService } from './organization.service'; +import { ListLabCasesDto } from '../cases/dto/cases.dto'; /** * Counterpart orgs (clinic↔lab). @@ -121,6 +122,40 @@ export class OrganizationController { return this.organizationService.deleteConnection(req.user.id, organizationId, connectionId); } + @Get('connections/:connectionId/cases') + @UseGuards(JwtAuthGuard) + @ApiOperation({ summary: 'List cases exchanged with a connected organization' }) + listConnectionCases( + @Req() req: { user: { id: string; organizationId?: string } }, + @Param('connectionId') connectionId: string, + @Query() query: ListLabCasesDto, + ) { + const organizationId = this.organizationService.getOrganizationIdFromUser(req.user); + return this.organizationService.listConnectionCases( + req.user.id, + organizationId, + connectionId, + query, + ); + } + + @Get('connections/:connectionId/cases/:caseId') + @UseGuards(JwtAuthGuard) + @ApiOperation({ summary: 'Get one case exchanged with a connected organization' }) + getConnectionCase( + @Req() req: { user: { id: string; organizationId?: string } }, + @Param('connectionId') connectionId: string, + @Param('caseId') caseId: string, + ) { + const organizationId = this.organizationService.getOrganizationIdFromUser(req.user); + return this.organizationService.getConnectionCase( + req.user.id, + organizationId, + connectionId, + caseId, + ); + } + @Post('invitations/:invitationId/link') @UseGuards(JwtAuthGuard) @ApiOperation({ summary: 'Get a shareable invite link for a pending invitation' }) diff --git a/backend/src/modules/organization/organization.module.ts b/backend/src/modules/organization/organization.module.ts index d36aef2..b8626c4 100644 --- a/backend/src/modules/organization/organization.module.ts +++ b/backend/src/modules/organization/organization.module.ts @@ -1,9 +1,11 @@ import { Module } from '@nestjs/common'; import { PrismaService } from '../../../prisma/prisma.service'; +import { CasesModule } from '../cases/cases.module'; import { OrganizationController } from './organization.controller'; import { OrganizationService } from './organization.service'; @Module({ + imports: [CasesModule], controllers: [OrganizationController], providers: [OrganizationService, PrismaService], }) diff --git a/backend/src/modules/organization/organization.service.ts b/backend/src/modules/organization/organization.service.ts index 6945d09..fc13621 100644 --- a/backend/src/modules/organization/organization.service.ts +++ b/backend/src/modules/organization/organization.service.ts @@ -9,6 +9,8 @@ import { LinkStatus } from '@prisma/client'; import * as bcrypt from 'bcrypt'; import { createHash, randomBytes } from 'crypto'; import { PrismaService } from '../../../prisma/prisma.service'; +import { ListLabCasesDto } from '../cases/dto/cases.dto'; +import { CasesService } from '../cases/cases.service'; import { AcceptOrganizationInviteDto } from './dto/accept-organization-invite.dto'; import { CreateConnectionRequestDto } from './dto/create-connection-request.dto'; import { InviteOrganizationDto } from './dto/invite-organization.dto'; @@ -28,7 +30,10 @@ import { RespondConnectionRequestDto } from './dto/respond-connection-request.dt */ @Injectable() export class OrganizationService { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly casesService: CasesService, + ) {} getOrganizationIdFromUser(user: { organizationId?: string }) { if (!user?.organizationId) { @@ -340,6 +345,64 @@ export class OrganizationService { }; } + async listConnectionCases( + userId: string, + organizationId: string, + connectionId: string, + query: ListLabCasesDto, + ) { + const actor = await this.getActorMembership(userId, organizationId); + if (!actor || !this.canEditOrganizations(actor)) { + throw new ForbiddenException('You do not have permission to manage organizations'); + } + + const { clinicOrganizationId, labOrganizationId, counterpart } = + await this.resolveActiveConnectionParties(connectionId, organizationId, actor); + + const result = await this.casesService.listBetweenOrganizations( + clinicOrganizationId, + labOrganizationId, + query, + ); + + return { + ...result, + data: { + ...result.data, + counterpart, + }, + }; + } + + async getConnectionCase( + userId: string, + organizationId: string, + connectionId: string, + caseId: string, + ) { + const actor = await this.getActorMembership(userId, organizationId); + if (!actor || !this.canEditOrganizations(actor)) { + throw new ForbiddenException('You do not have permission to manage organizations'); + } + + const { clinicOrganizationId, labOrganizationId, counterpart } = + await this.resolveActiveConnectionParties(connectionId, organizationId, actor); + + const result = await this.casesService.getOneBetweenOrganizations( + caseId, + clinicOrganizationId, + labOrganizationId, + ); + + return { + ...result, + data: { + ...result.data, + counterpart, + }, + }; + } + /** Re-issue a shareable URL for a pending invitation (rotates token; previous URL stops working). */ async getInvitationLink(userId: string, organizationId: string, invitationId: string) { const actor = await this.getActorMembership(userId, organizationId); @@ -640,6 +703,58 @@ export class OrganizationService { }; } + private async resolveActiveConnectionParties( + connectionId: string, + organizationId: string, + actor: { + organization: { type: { name: string } }; + }, + ) { + const link = await this.prisma.organizationLink.findFirst({ + where: { + id: connectionId, + status: LinkStatus.ACTIVE, + OR: [{ organizationAId: organizationId }, { organizationBId: organizationId }], + }, + include: { + organizationA: { select: { id: true, name: true, type: true } }, + organizationB: { select: { id: true, name: true, type: true } }, + }, + }); + + if (!link) { + throw new NotFoundException('Connected organization not found'); + } + + const counterpart = + link.organizationAId === organizationId ? link.organizationB : link.organizationA; + + const orgType = actor.organization.type.name; + if (orgType === 'CLINIC') { + if (counterpart.type.name !== 'LAB') { + throw new BadRequestException('Counterpart organization is not a lab'); + } + return { + clinicOrganizationId: organizationId, + labOrganizationId: counterpart.id, + counterpart: { id: counterpart.id, name: counterpart.name }, + }; + } + + if (orgType === 'LAB') { + if (counterpart.type.name !== 'CLINIC') { + throw new BadRequestException('Counterpart organization is not a clinic'); + } + return { + clinicOrganizationId: counterpart.id, + labOrganizationId: organizationId, + counterpart: { id: counterpart.id, name: counterpart.name }, + }; + } + + throw new BadRequestException('Unknown organization type'); + } + private async getActorMembership(userId: string, organizationId: string) { return this.prisma.membership.findFirst({ where: { userId, organizationId }, diff --git a/frontend/messages/en.json b/frontend/messages/en.json index e482d97..a8fcaab 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -606,7 +606,16 @@ "continueArrow": "Continue →", "planLabel": "Plan: {name} • {maxUsers} users", "counterpartClinic": "Clinic", - "counterpartLab": "Lab" + "counterpartLab": "Lab", + "viewCaseHistory": "View case history", + "caseHistoryBackToConnections": "← Back to connections", + "caseHistoryTitle": "Case history with {name}", + "caseHistorySubtitleClinic": "Cases you sent to this lab, including lab workflow status for each step.", + "caseHistorySubtitleLab": "Cases received from this clinic, including task status for each step.", + "caseHistoryEmpty": "No cases exchanged with this organization yet.", + "caseHistorySentToLab": "Sent to {name}", + "caseHistoryErrorLoadList": "Failed to load case history.", + "caseHistoryErrorLoadDetail": "Failed to load case details." }, "settings": { "accountTitle": "Account", diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index 39012cc..36e2021 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -606,7 +606,16 @@ "continueArrow": "ادامه →", "planLabel": "طرح: {name} • {maxUsers} کاربر", "counterpartClinic": "کلینیک", - "counterpartLab": "لابراتوار" + "counterpartLab": "لابراتوار", + "viewCaseHistory": "مشاهده تاریخچه پرونده‌ها", + "caseHistoryBackToConnections": "← بازگشت به اتصالات", + "caseHistoryTitle": "تاریخچه پرونده با {name}", + "caseHistorySubtitleClinic": "پرونده‌هایی که به این لابراتوار ارسال کرده‌اید، شامل وضعیت گردش کار لابراتوار برای هر مرحله.", + "caseHistorySubtitleLab": "پرونده‌های دریافتی از این کلینیک، شامل وضعیت وظایف برای هر مرحله.", + "caseHistoryEmpty": "هنوز پرونده‌ای با این سازمان رد و بدل نشده است.", + "caseHistorySentToLab": "ارسال شده به {name}", + "caseHistoryErrorLoadList": "بارگذاری تاریخچه پرونده ناموفق بود.", + "caseHistoryErrorLoadDetail": "بارگذاری جزئیات پرونده ناموفق بود." }, "settings": { "accountTitle": "حساب کاربری", diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index e878c98..0097a5a 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -606,7 +606,16 @@ "continueArrow": "Doorgaan →", "planLabel": "Plan: {name} • {maxUsers} gebruikers", "counterpartClinic": "Kliniek", - "counterpartLab": "Laboratorium" + "counterpartLab": "Laboratorium", + "viewCaseHistory": "Casusgeschiedenis bekijken", + "caseHistoryBackToConnections": "← Terug naar verbindingen", + "caseHistoryTitle": "Casusgeschiedenis met {name}", + "caseHistorySubtitleClinic": "Cases die u naar dit lab hebt gestuurd, inclusief lab-workflowstatus per stap.", + "caseHistorySubtitleLab": "Cases ontvangen van deze kliniek, inclusief taakstatus per stap.", + "caseHistoryEmpty": "Nog geen cases uitgewisseld met deze organisatie.", + "caseHistorySentToLab": "Verzonden naar {name}", + "caseHistoryErrorLoadList": "Casusgeschiedenis laden mislukt.", + "caseHistoryErrorLoadDetail": "Casusdetails laden mislukt." }, "settings": { "accountTitle": "Account", diff --git a/frontend/src/app/[locale]/(dashboard)/organizations/page.tsx b/frontend/src/app/[locale]/(dashboard)/organizations/page.tsx index c0ffcfc..cc1da47 100644 --- a/frontend/src/app/[locale]/(dashboard)/organizations/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/organizations/page.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useState } from 'react'; import { useTranslations } from 'next-intl'; import { useToast } from '@/lib/hooks/useToast'; -import { Check, Trash2, UserPlus, X } from 'lucide-react'; +import { Check, History, Trash2, UserPlus, X } from 'lucide-react'; import { useAuth } from '@/lib/hooks/useAuth'; import { notifyPendingConnectionsChanged } from '@/lib/hooks/usePendingConnectionsCount'; import { useOrganizationInviteLinkCopy } from '@/lib/hooks/useOrganizationInviteLinkCopy'; @@ -16,6 +16,7 @@ import { import { invitationTargetFromConnectionRow } from '@/components/invitations/organizationInviteLinks'; import { CopyInvitationLinkButton } from '@/components/ui/organizations/CopyInvitationLinkButton'; import { InvitationHistoryDialog } from '@/components/ui/organizations/InvitationHistoryDialog'; +import { ConnectionCaseHistoryContent } from '@/components/ui/organizations/ConnectionCaseHistoryContent'; import { Button } from '@/components/ui/shared/Button'; import { Badge, organizationConnectionStatusVariant } from '@/components/ui/shared/Badge'; import { Input } from '@/components/ui/shared/Input'; @@ -90,6 +91,9 @@ export default function OrganizationsPage() { const [historyOpen, setHistoryOpen] = useState(false); const [historyLoading, setHistoryLoading] = useState(false); const [historyItems, setHistoryItems] = useState([]); + const [caseHistoryConnection, setCaseHistoryConnection] = useState( + null, + ); const { copiedId, @@ -289,6 +293,15 @@ export default function OrganizationsPage() { return

{t('loadingOrganization')}

; } + if (caseHistoryConnection) { + return ( + setCaseHistoryConnection(null)} + /> + ); + } + return (
@@ -426,16 +439,27 @@ export default function OrganizationsPage() { )} {row.status === 'ACTIVE' && ( - + <> + + + )}
diff --git a/frontend/src/components/ui/organizations/ConnectionCaseHistoryContent.tsx b/frontend/src/components/ui/organizations/ConnectionCaseHistoryContent.tsx new file mode 100644 index 0000000..b95dc33 --- /dev/null +++ b/frontend/src/components/ui/organizations/ConnectionCaseHistoryContent.tsx @@ -0,0 +1,407 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { formatApiErrorMessage } from '@/components/shared/formatApiError'; +import { useAuth } from '@/lib/hooks/useAuth'; +import { useToast } from '@/lib/hooks/useToast'; +import { organizationApi } from '@/lib/api/organization'; +import { Badge, type BadgeVariant } from '@/components/ui/shared/Badge'; +import { Button } from '@/components/ui/shared/Button'; +import { SearchBar } from '@/components/ui/shared/SearchBar'; +import { ToastStack } from '@/components/ui/shared/Toast'; +import type { CounterpartItemDto } from '@/lib/api/organization'; +import type { LabCaseDetail, LabCaseListItem, LabTaskStatus } from '@/types/cases'; + +const TREATMENT_TYPE_KEYS = { + consultation: 'typeConsultation', + filling: 'typeFilling', + endo: 'typeEndo', + visit: 'typeVisit', + hygiene: 'typeHygiene', +} as const; + +const PAGE_SIZE = 20; + +function taskStatusVariant(status: LabTaskStatus): BadgeVariant { + switch (status) { + case 'COMPLETED': + return 'success'; + case 'IN_PROGRESS': + return 'default'; + default: + return 'warning'; + } +} + +function formatPatientName(patient: { firstName: string; lastName: string }) { + return `${patient.firstName} ${patient.lastName}`.trim(); +} + +function formatDateTime(value: string | null, locale: string) { + if (!value) return '—'; + return new Intl.DateTimeFormat(locale, { + dateStyle: 'medium', + timeStyle: 'short', + }).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}% +
+
+
+
+
+ ); +} + +interface ConnectionCaseHistoryContentProps { + connection: CounterpartItemDto; + onBack: () => void; +} + +export function ConnectionCaseHistoryContent({ + connection, + onBack, +}: ConnectionCaseHistoryContentProps) { + const t = useTranslations('organizations'); + const tCases = useTranslations('cases'); + const tTreatment = useTranslations('treatment'); + const tCommon = useTranslations('common'); + const { currentOrganization, user } = useAuth(); + const { showError, setError, messages: toastMessages } = useToast(); + + const [search, setSearch] = useState(''); + const [page, setPage] = useState(1); + const [cases, setCases] = useState([]); + const [pagination, setPagination] = useState({ + page: 1, + limit: PAGE_SIZE, + total: 0, + totalPages: 1, + }); + const [selectedCaseId, setSelectedCaseId] = useState(null); + const [selectedCase, setSelectedCase] = useState(null); + const [loadingList, setLoadingList] = useState(false); + const [loadingDetail, setLoadingDetail] = useState(false); + + const locale = user?.language ?? 'en'; + const isClinic = currentOrganization?.type === 'CLINIC'; + + const tRef = useRef(t); + tRef.current = t; + + const treatmentLabel = useCallback( + (type: string) => { + const key = TREATMENT_TYPE_KEYS[type as keyof typeof TREATMENT_TYPE_KEYS]; + return key ? tTreatment(key) : type; + }, + [tTreatment], + ); + + const statusOptions: { value: LabTaskStatus; label: string }[] = useMemo( + () => [ + { value: 'PENDING', label: tCases('statusPending') }, + { value: 'IN_PROGRESS', label: tCases('statusInProgress') }, + { value: 'COMPLETED', label: tCases('statusCompleted') }, + ], + [tCases], + ); + + useEffect(() => { + let cancelled = false; + + const timeout = setTimeout(() => { + void (async () => { + setLoadingList(true); + setError(''); + try { + const response = await organizationApi.listConnectionCases(connection.id, { + q: search.trim() || undefined, + page, + limit: PAGE_SIZE, + }); + if (cancelled) return; + setCases(response.data.items); + setPagination(response.data.pagination); + } catch (error: unknown) { + if (cancelled) return; + showError(formatApiErrorMessage(error, tRef.current('caseHistoryErrorLoadList'))); + } finally { + if (!cancelled) setLoadingList(false); + } + })(); + }, search ? 300 : 0); + + return () => { + cancelled = true; + clearTimeout(timeout); + }; + }, [search, page, connection.id, showError, setError]); + + useEffect(() => { + if (!selectedCaseId) { + setSelectedCase(null); + return; + } + + let cancelled = false; + + void (async () => { + setLoadingDetail(true); + setError(''); + try { + const response = await organizationApi.getConnectionCase(connection.id, selectedCaseId); + if (cancelled) return; + setSelectedCase(response.data); + } catch (error: unknown) { + if (cancelled) return; + showError(formatApiErrorMessage(error, tRef.current('caseHistoryErrorLoadDetail'))); + setSelectedCase(null); + } finally { + if (!cancelled) setLoadingDetail(false); + } + })(); + + return () => { + cancelled = true; + }; + }, [selectedCaseId, connection.id, showError, setError]); + + return ( +
+
+ +
+ +
+

+ {t('caseHistoryTitle', { name: connection.organizationName })} +

+

+ {isClinic ? t('caseHistorySubtitleClinic') : t('caseHistorySubtitleLab')} +

+
+ + + +
+
+ { + setSearch(value); + setPage(1); + }} + placeholder={tCases('searchPlaceholder')} + /> + +
+ {loadingList ? ( +

{tCommon('loading')}

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

{t('caseHistoryEmpty')}

+ ) : ( +
    + {cases.map((item) => { + const isActive = item.id === selectedCaseId; + + return ( +
  • + +
  • + ); + })} +
+ )} +
+ + {pagination.totalPages > 1 ? ( +
+ + + {tCases('pageSummary', { + page: pagination.page, + totalPages: pagination.totalPages, + total: pagination.total, + })} + + +
+ ) : null} +
+ +
+ {!selectedCaseId ? ( +

{tCases('selectCaseHint')}

+ ) : loadingDetail || !selectedCase ? ( +

{tCommon('loading')}

+ ) : ( +
+
+

+ {formatPatientName(selectedCase.patient)} +

+

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

+ {!isClinic ? ( +

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

+ ) : ( +

+ {t('caseHistorySentToLab', { name: connection.organizationName })} +

+ )} +

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

+
+

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

+ +
+ {selectedCase.labComment ? ( +

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

+ ) : null} +
+ + {selectedCase.details.length > 0 && ( +
+

+ {tCases('treatmentDetails')} +

+
    + {selectedCase.details.map((detail) => ( +
  • +
    {treatmentLabel(detail.treatmentType)}
    +
    + {tCases('teethLabel')}: {detail.teeth.join(', ') || '—'} +
    + {detail.comment ? ( +
    {detail.comment}
    + ) : null} +
  • + ))} +
+
+ )} + +
+

{tCases('tasksByTooth')}

+ {selectedCase.tasksByTooth.length === 0 ? ( +

{tCases('noTasks')}

+ ) : ( + selectedCase.tasksByTooth.map((group) => ( +
+
+ {tCases('toothGroupTitle', { + tooth: group.tooth, + type: treatmentLabel(group.treatmentType), + })} +
+
    + {group.tasks.map((task) => ( +
  • + + {task.stepOrder}. {task.stepLabel} + + + {statusOptions.find((opt) => opt.value === task.status)?.label ?? + task.status} + +
  • + ))} +
+
+ )) + )} +
+
+ )} +
+
+
+ ); +} diff --git a/frontend/src/lib/api/organization.ts b/frontend/src/lib/api/organization.ts index 0e54cd8..b9a4f74 100644 --- a/frontend/src/lib/api/organization.ts +++ b/frontend/src/lib/api/organization.ts @@ -1,4 +1,9 @@ import { apiClient } from './client'; +import type { + LabCaseDetail, + ListLabCasesParams, + PaginatedLabCases, +} from '@/types/cases'; export interface CounterpartSearchResultDto { id: string; @@ -130,4 +135,30 @@ export const organizationApi = { const response = await apiClient.post('/organizations/invitations/accept', body); return response.data; }, + + listConnectionCases: async ( + connectionId: string, + params: ListLabCasesParams = {}, + ): Promise<{ + success: boolean; + data: PaginatedLabCases & { counterpart: { id: string; name: string } }; + }> => { + const response = await apiClient.get(`/organizations/connections/${connectionId}/cases`, { + params, + }); + return response.data; + }, + + getConnectionCase: async ( + connectionId: string, + caseId: string, + ): Promise<{ + success: boolean; + data: LabCaseDetail & { counterpart: { id: string; name: string } }; + }> => { + const response = await apiClient.get( + `/organizations/connections/${connectionId}/cases/${caseId}`, + ); + return response.data; + }, };