From 64c7e5a25757c09c2caaf83a8ea531cfa597f888 Mon Sep 17 00:00:00 2001 From: Admin Date: Sun, 28 Jun 2026 14:49:37 +0330 Subject: [PATCH 01/17] feature: phase0 - Global patients + mobile normalization --- .../migration.sql | 28 ++ backend/prisma/schema.prisma | 33 +- backend/src/common/phone.spec.ts | 52 +++ backend/src/common/phone.ts | 54 +++ .../appointments/appointments.service.ts | 12 +- .../patients/dto/create-patient.dto.ts | 3 +- .../modules/patients/patients.controller.ts | 22 +- .../src/modules/patients/patients.service.ts | 136 +++++--- .../modules/treatments/treatments.service.ts | 6 +- frontend/messages/en.json | 8 +- frontend/messages/fa.json | 8 +- frontend/messages/nl.json | 8 +- .../(dashboard)/appointments/page.tsx | 23 +- .../[locale]/(dashboard)/patients/page.tsx | 311 +++++++++--------- .../appointments/AppointmentScheduleGrid.tsx | 9 +- .../AppointmentsPatientSearch.tsx | 3 +- .../ui/patient/CreatePatientModal.tsx | 10 +- .../ui/patient/PatientSearchSelect.tsx | 5 +- .../ui/patient/PatientSummaryCard.tsx | 3 +- frontend/src/lib/api/patients.ts | 3 +- frontend/src/lib/phone.ts | 44 +++ frontend/src/types/appointment.ts | 2 +- frontend/src/types/patient.ts | 12 +- 23 files changed, 535 insertions(+), 260 deletions(-) create mode 100644 backend/prisma/migrations/20260628120000_global_patients_mobile/migration.sql create mode 100644 backend/src/common/phone.spec.ts create mode 100644 backend/src/common/phone.ts create mode 100644 frontend/src/lib/phone.ts diff --git a/backend/prisma/migrations/20260628120000_global_patients_mobile/migration.sql b/backend/prisma/migrations/20260628120000_global_patients_mobile/migration.sql new file mode 100644 index 0000000..9625d89 --- /dev/null +++ b/backend/prisma/migrations/20260628120000_global_patients_mobile/migration.sql @@ -0,0 +1,28 @@ +-- Global patients: mobile is cloud-wide unique identity; org scope removed. +-- Test/dev data only — clear patient-linked rows before reshape. + +DELETE FROM "treatment_case_sends"; +DELETE FROM "treatment_case_attachments"; +DELETE FROM "treatment_cases"; +DELETE FROM "treatments"; +DELETE FROM "appointments"; +DELETE FROM "patients"; + +ALTER TABLE "patients" DROP CONSTRAINT IF EXISTS "patients_organizationId_fkey"; + +DROP INDEX IF EXISTS "patients_organizationId_createdAt_idx"; +DROP INDEX IF EXISTS "patients_organizationId_lastName_firstName_idx"; + +ALTER TABLE "patients" DROP COLUMN "organizationId"; +ALTER TABLE "patients" DROP COLUMN "phone"; + +ALTER TABLE "patients" ADD COLUMN "mobile" TEXT NOT NULL; +ALTER TABLE "patients" ADD COLUMN "createdByOrganizationId" TEXT; + +CREATE UNIQUE INDEX "patients_mobile_key" ON "patients"("mobile"); +CREATE INDEX "patients_lastName_firstName_idx" ON "patients"("lastName", "firstName"); + +ALTER TABLE "patients" + ADD CONSTRAINT "patients_createdByOrganizationId_fkey" + FOREIGN KEY ("createdByOrganizationId") REFERENCES "organizations"("id") + ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index ffe59bc..117d9e8 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -60,7 +60,7 @@ model Organization { sharedWithMe OrganizationLink[] @relation("OrganizationB") sharedWithOthers OrganizationLink[] @relation("OrganizationA") sentOrganizationInvitations OrganizationInvitation[] @relation("OrganizationInvitationInviter") - patients Patient[] + createdPatients Patient[] @relation("PatientCreatedBy") appointments Appointment[] treatments Treatment[] caseSends TreatmentCaseSend[] @@ -72,24 +72,23 @@ model Organization { } model Patient { - id String @id @default(uuid()) - organizationId String - firstName String - lastName String - phone String? - email String? - dateOfBirth DateTime? - notes String? - isActive Boolean @default(true) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(uuid()) + firstName String + lastName String + mobile String @unique + email String? + dateOfBirth DateTime? + notes String? + isActive Boolean @default(true) + createdByOrganizationId String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt - organization Organization @relation(fields: [organizationId], references: [id]) - treatments Treatment[] - appointments Appointment[] + createdByOrganization Organization? @relation("PatientCreatedBy", fields: [createdByOrganizationId], references: [id], onDelete: SetNull) + treatments Treatment[] + appointments Appointment[] - @@index([organizationId, createdAt]) - @@index([organizationId, lastName, firstName]) + @@index([lastName, firstName]) @@map("patients") } diff --git a/backend/src/common/phone.spec.ts b/backend/src/common/phone.spec.ts new file mode 100644 index 0000000..1838bd0 --- /dev/null +++ b/backend/src/common/phone.spec.ts @@ -0,0 +1,52 @@ +import { + formatMobileForDisplay, + isValidMobile, + mobileSearchDigits, + normalizeMobile, +} from './phone'; + +describe('normalizeMobile', () => { + it('normalizes 09-prefixed numbers', () => { + expect(normalizeMobile('09121234567')).toBe('+989121234567'); + }); + + it('normalizes without leading zero', () => { + expect(normalizeMobile('9121234567')).toBe('+989121234567'); + }); + + it('normalizes +98 prefix', () => { + expect(normalizeMobile('+989121234567')).toBe('+989121234567'); + }); + + it('normalizes 0098 prefix', () => { + expect(normalizeMobile('00989121234567')).toBe('+989121234567'); + }); + + it('normalizes spaced input', () => { + expect(normalizeMobile('0912 123 4567')).toBe('+989121234567'); + }); + + it('rejects invalid numbers', () => { + expect(normalizeMobile('123')).toBeNull(); + expect(normalizeMobile('')).toBeNull(); + }); +}); + +describe('isValidMobile', () => { + it('validates normalized mobile', () => { + expect(isValidMobile('+989121234567')).toBe(true); + expect(isValidMobile('09121234567')).toBe(false); + }); +}); + +describe('formatMobileForDisplay', () => { + it('formats E.164 to local spaced form', () => { + expect(formatMobileForDisplay('+989121234567')).toBe('0912 123 4567'); + }); +}); + +describe('mobileSearchDigits', () => { + it('strips non-digits', () => { + expect(mobileSearchDigits('+98 912-123-4567')).toBe('989121234567'); + }); +}); diff --git a/backend/src/common/phone.ts b/backend/src/common/phone.ts new file mode 100644 index 0000000..b2b0ada --- /dev/null +++ b/backend/src/common/phone.ts @@ -0,0 +1,54 @@ +/** Canonical Iran mobile: +989XXXXXXXXX (12 chars). */ +export const IR_MOBILE_REGEX = /^\+989\d{9}$/; + +/** + * Normalize user-entered mobile to E.164 for Iran (+98…). + * Accepts 09…, 9…, +98…, 0098… with optional spaces/dashes. + */ +export function normalizeMobile(input: string): string | null { + const trimmed = input?.trim(); + if (!trimmed) { + return null; + } + + let digits = trimmed.replace(/[^\d+]/g, ''); + if (digits.startsWith('+')) { + digits = digits.slice(1); + } + + digits = digits.replace(/\D/g, ''); + + if (digits.startsWith('0098')) { + digits = digits.slice(4); + } else if (digits.startsWith('98') && digits.length >= 12) { + digits = digits.slice(2); + } + + if (digits.startsWith('0') && digits.length === 11) { + digits = digits.slice(1); + } + + if (digits.length === 10 && digits.startsWith('9')) { + return `+98${digits}`; + } + + return null; +} + +export function isValidMobile(normalized: string): boolean { + return IR_MOBILE_REGEX.test(normalized); +} + +/** Display-friendly local format: 09XX XXX XXXX */ +export function formatMobileForDisplay(normalized: string): string { + if (!isValidMobile(normalized)) { + return normalized; + } + const local = `0${normalized.slice(3)}`; + return `${local.slice(0, 4)} ${local.slice(4, 7)} ${local.slice(7)}`; +} + +/** Strip to digits only for partial search matching. */ +export function mobileSearchDigits(input: string): string { + return input.replace(/\D/g, ''); +} diff --git a/backend/src/modules/appointments/appointments.service.ts b/backend/src/modules/appointments/appointments.service.ts index 2293082..769255e 100644 --- a/backend/src/modules/appointments/appointments.service.ts +++ b/backend/src/modules/appointments/appointments.service.ts @@ -96,7 +96,7 @@ export class AppointmentsService { }, include: { patient: { - select: { id: true, firstName: true, lastName: true, phone: true }, + select: { id: true, firstName: true, lastName: true, mobile: true }, }, }, orderBy: [{ startAt: 'asc' }], @@ -152,7 +152,7 @@ export class AppointmentsService { }, include: { patient: { - select: { id: true, firstName: true, lastName: true, phone: true }, + select: { id: true, firstName: true, lastName: true, mobile: true }, }, }, }); @@ -215,7 +215,7 @@ export class AppointmentsService { }, include: { patient: { - select: { id: true, firstName: true, lastName: true, phone: true }, + select: { id: true, firstName: true, lastName: true, mobile: true }, }, }, }); @@ -300,9 +300,9 @@ export class AppointmentsService { } } - private async ensurePatientInOrg(patientId: string, organizationId: string) { - const patient = await this.prisma.patient.findFirst({ - where: { id: patientId, organizationId }, + private async ensurePatientInOrg(patientId: string, _organizationId: string) { + const patient = await this.prisma.patient.findUnique({ + where: { id: patientId }, select: { id: true }, }); if (!patient) { diff --git a/backend/src/modules/patients/dto/create-patient.dto.ts b/backend/src/modules/patients/dto/create-patient.dto.ts index fa92769..5e72673 100644 --- a/backend/src/modules/patients/dto/create-patient.dto.ts +++ b/backend/src/modules/patients/dto/create-patient.dto.ts @@ -9,10 +9,9 @@ export class CreatePatientDto { @MaxLength(80) lastName: string; - @IsOptional() @IsString() @MaxLength(30) - phone?: string; + mobile: string; @IsOptional() @IsEmail() diff --git a/backend/src/modules/patients/patients.controller.ts b/backend/src/modules/patients/patients.controller.ts index 0e97c6e..46be5c1 100644 --- a/backend/src/modules/patients/patients.controller.ts +++ b/backend/src/modules/patients/patients.controller.ts @@ -3,7 +3,6 @@ import { Controller, Get, Param, - ParseIntPipe, Patch, Post, Query, @@ -25,30 +24,27 @@ export class PatientsController { constructor(private readonly patientsService: PatientsService) {} @Post() - @ApiOperation({ summary: 'Create a patient for current organization' }) + @ApiOperation({ summary: 'Create or return existing global patient by mobile' }) create(@Body() createPatientDto: CreatePatientDto, @Req() req) { const organizationId = this.patientsService.getOrganizationIdFromUser(req.user); return this.patientsService.create(createPatientDto, organizationId); } @Get() - @ApiOperation({ summary: 'List patients with search and pagination' }) - findAll(@Query() query: ListPatientsDto, @Req() req) { - const organizationId = this.patientsService.getOrganizationIdFromUser(req.user); - return this.patientsService.findAll(query, organizationId); + @ApiOperation({ summary: 'Search all patients globally' }) + findAll(@Query() query: ListPatientsDto) { + return this.patientsService.findAll(query); } @Get(':id') @ApiOperation({ summary: 'Get one patient by id' }) - findOne(@Param('id') id: string, @Req() req) { - const organizationId = this.patientsService.getOrganizationIdFromUser(req.user); - return this.patientsService.findOne(id, organizationId); + findOne(@Param('id') id: string) { + return this.patientsService.findOne(id); } @Patch(':id') - @ApiOperation({ summary: 'Update patient' }) - update(@Param('id') id: string, @Body() updatePatientDto: UpdatePatientDto, @Req() req) { - const organizationId = this.patientsService.getOrganizationIdFromUser(req.user); - return this.patientsService.update(id, updatePatientDto, organizationId); + @ApiOperation({ summary: 'Update global patient record' }) + update(@Param('id') id: string, @Body() updatePatientDto: UpdatePatientDto) { + return this.patientsService.update(id, updatePatientDto); } } diff --git a/backend/src/modules/patients/patients.service.ts b/backend/src/modules/patients/patients.service.ts index ac99936..6a1f6fc 100644 --- a/backend/src/modules/patients/patients.service.ts +++ b/backend/src/modules/patients/patients.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../../../prisma/prisma.service'; +import { isValidMobile, mobileSearchDigits, normalizeMobile } from '../../common/phone'; import { CreatePatientDto } from './dto/create-patient.dto'; import { ListPatientsDto } from './dto/list-patients.dto'; import { UpdatePatientDto } from './dto/update-patient.dto'; @@ -9,34 +10,38 @@ export class PatientsService { constructor(private readonly prisma: PrismaService) {} async create(createPatientDto: CreatePatientDto, organizationId: string) { + const mobile = this.resolveMobile(createPatientDto.mobile); + + const existing = await this.prisma.patient.findUnique({ + where: { mobile }, + }); + + if (existing) { + return { success: true, data: existing, existing: true as const }; + } + const patient = await this.prisma.patient.create({ data: { - ...createPatientDto, + firstName: createPatientDto.firstName.trim(), + lastName: createPatientDto.lastName.trim(), + mobile, + email: createPatientDto.email?.trim() || null, + notes: createPatientDto.notes?.trim() || null, dateOfBirth: createPatientDto.dateOfBirth ? new Date(createPatientDto.dateOfBirth) : null, - organizationId, + createdByOrganizationId: organizationId, }, }); - return { success: true, data: patient }; + return { success: true, data: patient, existing: false as const }; } - async findAll(query: ListPatientsDto, organizationId: string) { + async findAll(query: ListPatientsDto) { const { page = 1, limit = 10, q } = query; const skip = (page - 1) * limit; - const where = { - organizationId, - ...(q - ? { - OR: [ - { firstName: { contains: q, mode: 'insensitive' as const } }, - { lastName: { contains: q, mode: 'insensitive' as const } }, - { email: { contains: q, mode: 'insensitive' as const } }, - { phone: { contains: q, mode: 'insensitive' as const } }, - ], - } - : {}), - }; + const where = q?.trim() + ? this.buildSearchWhere(q.trim()) + : {}; const [items, total] = await Promise.all([ this.prisma.patient.findMany({ @@ -62,9 +67,9 @@ export class PatientsService { }; } - async findOne(id: string, organizationId: string) { - const patient = await this.prisma.patient.findFirst({ - where: { id, organizationId }, + async findOne(id: string) { + const patient = await this.prisma.patient.findUnique({ + where: { id }, }); if (!patient) { @@ -74,35 +79,92 @@ export class PatientsService { return { success: true, data: patient }; } - async update(id: string, updatePatientDto: UpdatePatientDto, organizationId: string) { - await this.ensurePatient(id, organizationId); + async update(id: string, updatePatientDto: UpdatePatientDto) { + await this.ensurePatient(id); + + const data: { + firstName?: string; + lastName?: string; + mobile?: string; + email?: string | null; + notes?: string | null; + dateOfBirth?: Date | null; + } = {}; + + if (updatePatientDto.firstName !== undefined) { + data.firstName = updatePatientDto.firstName.trim(); + } + if (updatePatientDto.lastName !== undefined) { + data.lastName = updatePatientDto.lastName.trim(); + } + if (updatePatientDto.mobile !== undefined) { + data.mobile = this.resolveMobile(updatePatientDto.mobile); + } + if (updatePatientDto.email !== undefined) { + data.email = updatePatientDto.email?.trim() || null; + } + if (updatePatientDto.notes !== undefined) { + data.notes = updatePatientDto.notes?.trim() || null; + } + if (updatePatientDto.dateOfBirth !== undefined) { + data.dateOfBirth = updatePatientDto.dateOfBirth + ? new Date(updatePatientDto.dateOfBirth) + : null; + } const patient = await this.prisma.patient.update({ where: { id }, - data: { - ...updatePatientDto, - dateOfBirth: updatePatientDto.dateOfBirth ? new Date(updatePatientDto.dateOfBirth) : undefined, - }, + data, }); return { success: true, data: patient }; } - private async ensurePatient(id: string, organizationId: string) { - const patient = await this.prisma.patient.findFirst({ - where: { id, organizationId }, - select: { id: true }, - }); - - if (!patient) { - throw new NotFoundException('Patient not found'); - } - } - getOrganizationIdFromUser(user: { organizationId?: string }) { if (!user?.organizationId) { throw new BadRequestException('Organization is not selected'); } return user.organizationId; } + + private buildSearchWhere(q: string) { + const orConditions: Array> = [ + { firstName: { contains: q, mode: 'insensitive' as const } }, + { lastName: { contains: q, mode: 'insensitive' as const } }, + { email: { contains: q, mode: 'insensitive' as const } }, + ]; + + const normalized = normalizeMobile(q); + if (normalized) { + orConditions.push({ mobile: normalized }); + } else { + const digits = mobileSearchDigits(q); + if (digits.length >= 3) { + orConditions.push({ mobile: { contains: digits } }); + } + } + + return { OR: orConditions }; + } + + private resolveMobile(raw: string): string { + const mobile = normalizeMobile(raw); + if (!mobile || !isValidMobile(mobile)) { + throw new BadRequestException( + 'Invalid mobile number. Use a valid Iran mobile (e.g. 09121234567 or +989121234567).', + ); + } + return mobile; + } + + private async ensurePatient(id: string) { + const patient = await this.prisma.patient.findUnique({ + where: { id }, + select: { id: true }, + }); + + if (!patient) { + throw new NotFoundException('Patient not found'); + } + } } diff --git a/backend/src/modules/treatments/treatments.service.ts b/backend/src/modules/treatments/treatments.service.ts index e44fb96..cf09b4f 100644 --- a/backend/src/modules/treatments/treatments.service.ts +++ b/backend/src/modules/treatments/treatments.service.ts @@ -549,9 +549,9 @@ export class TreatmentsService { ]); } - private async ensurePatientInOrg(patientId: string, organizationId: string) { - const patient = await this.prisma.patient.findFirst({ - where: { id: patientId, organizationId }, + private async ensurePatientInOrg(patientId: string, _organizationId: string) { + const patient = await this.prisma.patient.findUnique({ + where: { id: patientId }, select: { id: true }, }); if (!patient) { diff --git a/frontend/messages/en.json b/frontend/messages/en.json index f58cb41..3ed3e7c 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -296,15 +296,17 @@ "errorSavePatient": "Failed to save patient.", "firstName": "First name", "lastName": "Last name", - "phone": "Phone", + "mobile": "Mobile", + "mobilePlaceholder": "09121234567", + "mobileLabel": "Mobile:", + "patientAlreadyExists": "A patient with this mobile already exists: {firstName} {lastName}. They were selected for you.", "savePatient": "Save Patient", "dialogTitle": "New patient", - "searchPlaceholder": "Search patients by name, phone, email", + "searchPlaceholder": "Search patients by name, mobile, email", "loadingPatients": "Loading patients...", "noResults": "No patients found for this search.", "noContact": "No contact", "selectPatient": "Select a patient to view details.", - "phoneLabel": "Phone:", "emailLabel": "Email:", "statusLabel": "Status:", "statusActive": "Active", diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index e87d189..3de25cc 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -296,15 +296,17 @@ "errorSavePatient": "ذخیره بیمار ناموفق بود.", "firstName": "نام", "lastName": "نام خانوادگی", - "phone": "تلفن", + "mobile": "موبایل", + "mobilePlaceholder": "09121234567", + "mobileLabel": "موبایل:", + "patientAlreadyExists": "بیماری با این شماره موبایل از قبل وجود دارد: {firstName} {lastName}. برای شما انتخاب شد.", "savePatient": "ذخیره بیمار", "dialogTitle": "بیمار جدید", - "searchPlaceholder": "جستجوی بیماران بر اساس نام، تلفن، ایمیل", + "searchPlaceholder": "جستجوی بیماران بر اساس نام، موبایل، ایمیل", "loadingPatients": "در حال بارگذاری بیماران...", "noResults": "هیچ بیماری برای این جستجو یافت نشد.", "noContact": "بدون اطلاعات تماس", "selectPatient": "برای مشاهده جزئیات، یک بیمار را انتخاب کنید.", - "phoneLabel": "تلفن:", "emailLabel": "ایمیل:", "statusLabel": "وضعیت:", "statusActive": "فعال", diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index 517a742..789e111 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -296,15 +296,17 @@ "errorSavePatient": "Patiënt opslaan mislukt.", "firstName": "Voornaam", "lastName": "Achternaam", - "phone": "Telefoon", + "mobile": "Mobiel", + "mobilePlaceholder": "0612345678", + "mobileLabel": "Mobiel:", + "patientAlreadyExists": "Er bestaat al een patiënt met dit mobiele nummer: {firstName} {lastName}. Deze is voor u geselecteerd.", "savePatient": "Patiënt opslaan", "dialogTitle": "Nieuwe patiënt", - "searchPlaceholder": "Zoek patiënten op naam, telefoon, e-mail", + "searchPlaceholder": "Zoek patiënten op naam, mobiel, e-mail", "loadingPatients": "Patiënten laden...", "noResults": "Geen patiënten gevonden voor deze zoekopdracht.", "noContact": "Geen contact", "selectPatient": "Selecteer een patiënt om details te bekijken.", - "phoneLabel": "Telefoon:", "emailLabel": "E-mail:", "statusLabel": "Status:", "statusActive": "Actief", diff --git a/frontend/src/app/[locale]/(dashboard)/appointments/page.tsx b/frontend/src/app/[locale]/(dashboard)/appointments/page.tsx index 10207e0..9d0a025 100644 --- a/frontend/src/app/[locale]/(dashboard)/appointments/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/appointments/page.tsx @@ -24,7 +24,7 @@ import { compareLocalDayStart, getLocalDayIsoRange, startOfLocalDay } from '@/co const EMPTY_PATIENT_FORM: CreatePatientInput = { firstName: '', lastName: '', - phone: '', + mobile: '', email: '', }; @@ -152,12 +152,21 @@ export default function AppointmentsPage() { setPatientForm(EMPTY_PATIENT_FORM); await loadPatientsSearch(search); setSelectedPatient(response.data); - toast.showSuccess( - t('successPatientSaved', { - firstName: response.data.firstName, - lastName: response.data.lastName, - }), - ); + if (response.existing) { + toast.showInfo( + tPatients('patientAlreadyExists', { + firstName: response.data.firstName, + lastName: response.data.lastName, + }), + ); + } else { + toast.showSuccess( + t('successPatientSaved', { + firstName: response.data.firstName, + lastName: response.data.lastName, + }), + ); + } } catch (err: unknown) { const message = err && typeof err === 'object' && 'message' in err diff --git a/frontend/src/app/[locale]/(dashboard)/patients/page.tsx b/frontend/src/app/[locale]/(dashboard)/patients/page.tsx index 61a5f21..ddc52b1 100644 --- a/frontend/src/app/[locale]/(dashboard)/patients/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/patients/page.tsx @@ -1,151 +1,160 @@ -'use client'; - -import { useEffect, useMemo, useState } from 'react'; -import { useTranslations } from 'next-intl'; -import { Button } from '@/components/ui/shared/Button'; -import { ToastStack } from '@/components/ui/shared/Toast'; -import { patientsApi } from '@/lib/api/patients'; -import { formatApiErrorMessage } from '@/components/shared/formatApiError'; -import { useAuth } from '@/lib/hooks/useAuth'; -import { useToast } from '@/lib/hooks/useToast'; -import { hasPermission } from '@/components/shared/permissions'; -import { CreatePatientInput, Patient } from '@/types/patient'; -import { PatientSearchSelect } from '@/components/ui/patient/PatientSearchSelect'; -import { CreatePatientModal } from '@/components/ui/patient/CreatePatientModal'; -import { PatientSummaryCard } from '@/components/ui/patient/PatientSummaryCard'; - -const EMPTY_PATIENT_FORM: CreatePatientInput = { - firstName: '', - lastName: '', - phone: '', - email: '', -}; - -export default function PatientsPage() { - const t = useTranslations('patients'); - const tCommon = useTranslations('common'); - const { currentOrganization } = useAuth(); - const toast = useToast(); - const [search, setSearch] = useState(''); - const [patients, setPatients] = useState([]); - const [selectedPatient, setSelectedPatient] = useState(); - const [loadingPatients, setLoadingPatients] = useState(false); - const [isCreateOpen, setIsCreateOpen] = useState(false); - const [savingPatient, setSavingPatient] = useState(false); - const [patientForm, setPatientForm] = useState(EMPTY_PATIENT_FORM); - const canEditPatients = hasPermission(currentOrganization, 'TAB_PATIENTS_EDIT'); - - const sortedPatients = useMemo( - () => - [...patients].sort((a, b) => - `${a.firstName} ${a.lastName}`.localeCompare(`${b.firstName} ${b.lastName}`), - ), - [patients], - ); - - useEffect(() => { - const timeout = setTimeout(() => { - void loadPatients(search); - }, 300); - return () => clearTimeout(timeout); - }, [search]); - - useEffect(() => { - void loadPatients(''); - }, []); - - async function loadPatients(q: string) { - setLoadingPatients(true); - toast.setError(''); - try { - const response = await patientsApi.list({ q, page: 1, limit: 25 }); - const items = response.data.items; - setPatients(items); - - if (selectedPatient) { - const freshSelected = items.find((item) => item.id === selectedPatient.id); - setSelectedPatient(freshSelected); - } - } catch (error: unknown) { - toast.showError(formatApiErrorMessage(error, t('errorLoadPatients'))); - } finally { - setLoadingPatients(false); - } - } - - async function handleCreatePatient() { - setSavingPatient(true); - toast.setError(''); - try { - const response = await patientsApi.create(patientForm); - setIsCreateOpen(false); - setPatientForm(EMPTY_PATIENT_FORM); - await loadPatients(search); - setSelectedPatient(response.data); - toast.showSuccess( - t('successPatientSaved', { - firstName: response.data.firstName, - lastName: response.data.lastName, - }), - ); - } catch (error: unknown) { - toast.showError(formatApiErrorMessage(error, t('errorSavePatient'))); - } finally { - setSavingPatient(false); - } - } - - return ( -
-
-

{t('title')}

- -
- - - - {isCreateOpen && ( - setPatientForm((prev) => ({ ...prev, ...patch }))} - onSubmit={() => void handleCreatePatient()} - onClose={() => { - setIsCreateOpen(false); - setPatientForm(EMPTY_PATIENT_FORM); - }} - loading={savingPatient} - /> - )} - -
-
- -
- -
- -
-
-
- ); -} +'use client'; + +import { useEffect, useMemo, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { Button } from '@/components/ui/shared/Button'; +import { ToastStack } from '@/components/ui/shared/Toast'; +import { patientsApi } from '@/lib/api/patients'; +import { formatApiErrorMessage } from '@/components/shared/formatApiError'; +import { useAuth } from '@/lib/hooks/useAuth'; +import { useToast } from '@/lib/hooks/useToast'; +import { hasPermission } from '@/components/shared/permissions'; +import { CreatePatientInput, Patient } from '@/types/patient'; +import { PatientSearchSelect } from '@/components/ui/patient/PatientSearchSelect'; +import { CreatePatientModal } from '@/components/ui/patient/CreatePatientModal'; +import { PatientSummaryCard } from '@/components/ui/patient/PatientSummaryCard'; + +const EMPTY_PATIENT_FORM: CreatePatientInput = { + firstName: '', + lastName: '', + mobile: '', + email: '', +}; + +export default function PatientsPage() { + const t = useTranslations('patients'); + const tCommon = useTranslations('common'); + const { currentOrganization } = useAuth(); + const toast = useToast(); + const [search, setSearch] = useState(''); + const [patients, setPatients] = useState([]); + const [selectedPatient, setSelectedPatient] = useState(); + const [loadingPatients, setLoadingPatients] = useState(false); + const [isCreateOpen, setIsCreateOpen] = useState(false); + const [savingPatient, setSavingPatient] = useState(false); + const [patientForm, setPatientForm] = useState(EMPTY_PATIENT_FORM); + const canEditPatients = hasPermission(currentOrganization, 'TAB_PATIENTS_EDIT'); + + const sortedPatients = useMemo( + () => + [...patients].sort((a, b) => + `${a.firstName} ${a.lastName}`.localeCompare(`${b.firstName} ${b.lastName}`), + ), + [patients], + ); + + useEffect(() => { + const timeout = setTimeout(() => { + void loadPatients(search); + }, 300); + return () => clearTimeout(timeout); + }, [search]); + + useEffect(() => { + void loadPatients(''); + }, []); + + async function loadPatients(q: string) { + setLoadingPatients(true); + toast.setError(''); + try { + const response = await patientsApi.list({ q, page: 1, limit: 25 }); + const items = response.data.items; + setPatients(items); + + if (selectedPatient) { + const freshSelected = items.find((item) => item.id === selectedPatient.id); + setSelectedPatient(freshSelected); + } + } catch (error: unknown) { + toast.showError(formatApiErrorMessage(error, t('errorLoadPatients'))); + } finally { + setLoadingPatients(false); + } + } + + async function handleCreatePatient() { + setSavingPatient(true); + toast.setError(''); + try { + const response = await patientsApi.create(patientForm); + setIsCreateOpen(false); + setPatientForm(EMPTY_PATIENT_FORM); + await loadPatients(search); + setSelectedPatient(response.data); + if (response.existing) { + toast.showInfo( + t('patientAlreadyExists', { + firstName: response.data.firstName, + lastName: response.data.lastName, + }), + ); + } else { + toast.showSuccess( + t('successPatientSaved', { + firstName: response.data.firstName, + lastName: response.data.lastName, + }), + ); + } + } catch (error: unknown) { + toast.showError(formatApiErrorMessage(error, t('errorSavePatient'))); + } finally { + setSavingPatient(false); + } + } + + return ( +
+
+

{t('title')}

+ +
+ + + + {isCreateOpen && ( + setPatientForm((prev) => ({ ...prev, ...patch }))} + onSubmit={() => void handleCreatePatient()} + onClose={() => { + setIsCreateOpen(false); + setPatientForm(EMPTY_PATIENT_FORM); + }} + loading={savingPatient} + /> + )} + +
+
+ +
+ +
+ +
+
+
+ ); +} diff --git a/frontend/src/components/ui/appointments/AppointmentScheduleGrid.tsx b/frontend/src/components/ui/appointments/AppointmentScheduleGrid.tsx index 2f90b9d..64ca665 100644 --- a/frontend/src/components/ui/appointments/AppointmentScheduleGrid.tsx +++ b/frontend/src/components/ui/appointments/AppointmentScheduleGrid.tsx @@ -20,6 +20,7 @@ import { } from '@/components/appointments/appointmentOverlapLayout'; import { purposeStyle } from '@/components/ui/appointments/appointmentPurposeStyles'; import { AppointmentOverlapPopover } from '@/components/ui/appointments/AppointmentOverlapPopover'; +import { formatMobileForDisplay } from '@/lib/phone'; import { startOfLocalDay } from '@/components/appointments/appointmentTime'; const HOUR_PX = 80; @@ -300,7 +301,9 @@ export function AppointmentScheduleGrid({ clusterSize > 1 ? t('overlappingChoose', { count: clusterSize }) : null, - !isUnderOneHour && apt.patient.phone ? apt.patient.phone : null, + !isUnderOneHour && apt.patient.mobile + ? formatMobileForDisplay(apt.patient.mobile) + : null, ] .filter(Boolean) .join(' · '); @@ -338,10 +341,10 @@ export function AppointmentScheduleGrid({ {patientName} {!isUnderOneHour && - apt.patient.phone && + apt.patient.mobile && lane.laneCount === 1 && ( - {apt.patient.phone} + {formatMobileForDisplay(apt.patient.mobile)} )} {!isUnderOneHour && clusterSize > 1 && ( diff --git a/frontend/src/components/ui/appointments/AppointmentsPatientSearch.tsx b/frontend/src/components/ui/appointments/AppointmentsPatientSearch.tsx index 5dce8a1..a238353 100644 --- a/frontend/src/components/ui/appointments/AppointmentsPatientSearch.tsx +++ b/frontend/src/components/ui/appointments/AppointmentsPatientSearch.tsx @@ -4,6 +4,7 @@ import { Search } from 'lucide-react'; import { useTranslations } from 'next-intl'; import { Button } from '@/components/ui/shared/Button'; import { Input } from '@/components/ui/shared/Input'; +import { formatMobileForDisplay } from '@/lib/phone'; import type { Patient } from '@/types/patient'; interface AppointmentsPatientSearchProps { @@ -82,7 +83,7 @@ export function AppointmentsPatientSearch({ {patient.firstName} {patient.lastName}

- {patient.phone || patient.email || tPatients('noContact')} + {formatMobileForDisplay(patient.mobile) || patient.email || tPatients('noContact')}

); diff --git a/frontend/src/components/ui/patient/CreatePatientModal.tsx b/frontend/src/components/ui/patient/CreatePatientModal.tsx index 7e0f44f..6f792ad 100644 --- a/frontend/src/components/ui/patient/CreatePatientModal.tsx +++ b/frontend/src/components/ui/patient/CreatePatientModal.tsx @@ -5,6 +5,7 @@ import { Button } from '@/components/ui/shared/Button'; import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton'; import { Input } from '@/components/ui/shared/Input'; import { CreatePatientInput } from '@/types/patient'; +import { isValidMobile, normalizeMobile } from '@/lib/phone'; interface CreatePatientModalProps { isOpen: boolean; @@ -49,9 +50,10 @@ function CreatePatientFormFields({ onChange={(e) => onChange({ lastName: e.target.value })} /> onChange({ phone: e.target.value })} + label={t('mobile')} + value={formData.mobile || ''} + onChange={(e) => onChange({ mobile: e.target.value })} + placeholder={t('mobilePlaceholder')} /> {t('savePatient')} diff --git a/frontend/src/components/ui/patient/PatientSearchSelect.tsx b/frontend/src/components/ui/patient/PatientSearchSelect.tsx index 4f4d707..0ca43c0 100644 --- a/frontend/src/components/ui/patient/PatientSearchSelect.tsx +++ b/frontend/src/components/ui/patient/PatientSearchSelect.tsx @@ -3,6 +3,7 @@ import { useTranslations } from 'next-intl'; import { Search } from 'lucide-react'; import { Input } from '@/components/ui/shared/Input'; +import { formatMobileForDisplay } from '@/lib/phone'; import { Patient } from '@/types/patient'; interface PatientSearchSelectProps { @@ -56,7 +57,9 @@ export function PatientSearchSelect({

{patient.firstName} {patient.lastName}

-

{patient.phone || patient.email || t('noContact')}

+

+ {formatMobileForDisplay(patient.mobile) || patient.email || t('noContact')} +

); })} diff --git a/frontend/src/components/ui/patient/PatientSummaryCard.tsx b/frontend/src/components/ui/patient/PatientSummaryCard.tsx index fb2e1aa..63974d2 100644 --- a/frontend/src/components/ui/patient/PatientSummaryCard.tsx +++ b/frontend/src/components/ui/patient/PatientSummaryCard.tsx @@ -1,6 +1,7 @@ 'use client'; import { useTranslations } from 'next-intl'; +import { formatMobileForDisplay } from '@/lib/phone'; import { Patient } from '@/types/patient'; interface PatientSummaryCardProps { @@ -24,7 +25,7 @@ export function PatientSummaryCard({ patient }: PatientSummaryCardProps) { {patient.firstName} {patient.lastName}

- {t('phoneLabel')} {patient.phone || t('emptyValue')} + {t('mobileLabel')} {formatMobileForDisplay(patient.mobile)}

{t('emailLabel')} {patient.email || t('emptyValue')} diff --git a/frontend/src/lib/api/patients.ts b/frontend/src/lib/api/patients.ts index 8578c9c..27309e6 100644 --- a/frontend/src/lib/api/patients.ts +++ b/frontend/src/lib/api/patients.ts @@ -1,6 +1,7 @@ import { apiClient } from './client'; import { CreatePatientInput, + CreatePatientResponse, Patient, PatientsListResponse, } from '@/types/patient'; @@ -11,7 +12,7 @@ export const patientsApi = { return response.data; }, - create: async (data: CreatePatientInput): Promise<{ success: boolean; data: Patient }> => { + create: async (data: CreatePatientInput): Promise => { const response = await apiClient.post('/patients', data); return response.data; }, diff --git a/frontend/src/lib/phone.ts b/frontend/src/lib/phone.ts new file mode 100644 index 0000000..df28c3f --- /dev/null +++ b/frontend/src/lib/phone.ts @@ -0,0 +1,44 @@ +/** Canonical Iran mobile: +989XXXXXXXXX */ +export const IR_MOBILE_REGEX = /^\+989\d{9}$/; + +export function normalizeMobile(input: string): string | null { + const trimmed = input?.trim(); + if (!trimmed) { + return null; + } + + let digits = trimmed.replace(/[^\d+]/g, ''); + if (digits.startsWith('+')) { + digits = digits.slice(1); + } + + digits = digits.replace(/\D/g, ''); + + if (digits.startsWith('0098')) { + digits = digits.slice(4); + } else if (digits.startsWith('98') && digits.length >= 12) { + digits = digits.slice(2); + } + + if (digits.startsWith('0') && digits.length === 11) { + digits = digits.slice(1); + } + + if (digits.length === 10 && digits.startsWith('9')) { + return `+98${digits}`; + } + + return null; +} + +export function isValidMobile(normalized: string): boolean { + return IR_MOBILE_REGEX.test(normalized); +} + +export function formatMobileForDisplay(normalized: string): string { + if (!isValidMobile(normalized)) { + return normalized; + } + const local = `0${normalized.slice(3)}`; + return `${local.slice(0, 4)} ${local.slice(4, 7)} ${local.slice(7)}`; +} diff --git a/frontend/src/types/appointment.ts b/frontend/src/types/appointment.ts index 3614dd6..2435c80 100644 --- a/frontend/src/types/appointment.ts +++ b/frontend/src/types/appointment.ts @@ -25,5 +25,5 @@ export interface AppointmentRecord { startAt: string; endAt: string; purpose: string; - patient: Pick; + patient: Pick; } diff --git a/frontend/src/types/patient.ts b/frontend/src/types/patient.ts index bb0e8c5..0fa9aee 100644 --- a/frontend/src/types/patient.ts +++ b/frontend/src/types/patient.ts @@ -1,13 +1,13 @@ export interface Patient { id: string; - organizationId: string; firstName: string; lastName: string; - phone?: string | null; + mobile: string; email?: string | null; dateOfBirth?: string | null; notes?: string | null; isActive: boolean; + createdByOrganizationId?: string | null; createdAt: string; updatedAt: string; } @@ -15,7 +15,7 @@ export interface Patient { export interface CreatePatientInput { firstName: string; lastName: string; - phone?: string; + mobile: string; email?: string; dateOfBirth?: string; notes?: string; @@ -33,3 +33,9 @@ export interface PatientsListResponse { }; }; } + +export interface CreatePatientResponse { + success: boolean; + data: Patient; + existing?: boolean; +} -- 2.53.0.windows.1 From dc965b252896de74a136060cdee6bf8ec4b675b9 Mon Sep 17 00:00:00 2001 From: Admin Date: Sun, 28 Jun 2026 14:59:06 +0330 Subject: [PATCH 02/17] feature: phase1 - org-type navigation, Cases permissions, staff filtering, and route guards. --- .../migration.sql | 15 +++ backend/prisma/seed.ts | 4 + backend/src/common/guards/clinic-org.guard.ts | 25 +++++ backend/src/common/organization-type.ts | 100 +++++++++++++++++ backend/src/common/permissions.ts | 3 + .../appointments/appointments.controller.ts | 3 +- .../appointments/appointments.module.ts | 3 +- backend/src/modules/auth/auth.service.ts | 9 +- .../modules/patients/patients.controller.ts | 3 +- .../src/modules/patients/patients.module.ts | 3 +- backend/src/modules/staff/staff.service.ts | 10 +- .../treatments/treatments.controller.ts | 3 +- .../modules/treatments/treatments.module.ts | 3 +- frontend/messages/en.json | 6 ++ frontend/messages/fa.json | 6 ++ frontend/messages/nl.json | 6 ++ .../app/[locale]/(dashboard)/cases/page.tsx | 14 +++ .../src/app/[locale]/(dashboard)/layout.tsx | 15 +-- .../app/[locale]/(dashboard)/staff/page.tsx | 18 ++-- frontend/src/components/shared/permissions.ts | 102 +++++++++++++++--- .../components/staff/staff-permission-form.ts | 43 +++++--- frontend/src/components/ui/shared/Sidebar.tsx | 58 ++++++---- 22 files changed, 376 insertions(+), 76 deletions(-) create mode 100644 backend/prisma/migrations/20260628130000_add_cases_permissions/migration.sql create mode 100644 backend/src/common/guards/clinic-org.guard.ts create mode 100644 backend/src/common/organization-type.ts create mode 100644 frontend/src/app/[locale]/(dashboard)/cases/page.tsx diff --git a/backend/prisma/migrations/20260628130000_add_cases_permissions/migration.sql b/backend/prisma/migrations/20260628130000_add_cases_permissions/migration.sql new file mode 100644 index 0000000..e44243d --- /dev/null +++ b/backend/prisma/migrations/20260628130000_add_cases_permissions/migration.sql @@ -0,0 +1,15 @@ +-- Add Cases tab permissions for lab organizations + +INSERT INTO "features" ("id", "name", "description", "organizationTypeId") +VALUES (gen_random_uuid(), 'Cases', 'Lab cases inbox', NULL) +ON CONFLICT ("name") DO NOTHING; + +INSERT INTO "permissions" ("id", "name", "description", "featureId") +SELECT gen_random_uuid(), v.name, NULL, f.id +FROM (VALUES + ('TAB_CASES_READ'), + ('TAB_CASES_EDIT') +) AS v(name) +CROSS JOIN "features" f +WHERE f.name = 'Cases' +ON CONFLICT ("name") DO NOTHING; diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts index 6023967..171ac79 100644 --- a/backend/prisma/seed.ts +++ b/backend/prisma/seed.ts @@ -89,6 +89,10 @@ async function main() { name: 'Treatment', permissions: ['TAB_TREATMENT_READ', 'TAB_TREATMENT_EDIT'], }, + { + name: 'Cases', + permissions: ['TAB_CASES_READ', 'TAB_CASES_EDIT'], + }, { name: 'Billing', permissions: ['TAB_BILLING_READ', 'TAB_BILLING_EDIT'], diff --git a/backend/src/common/guards/clinic-org.guard.ts b/backend/src/common/guards/clinic-org.guard.ts new file mode 100644 index 0000000..5b16b09 --- /dev/null +++ b/backend/src/common/guards/clinic-org.guard.ts @@ -0,0 +1,25 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { PrismaService } from '../../../prisma/prisma.service'; +import { assertClinicOrganization } from '../../common/organization-type'; + +@Injectable() +export class ClinicOrgGuard implements CanActivate { + constructor(private readonly prisma: PrismaService) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest<{ user?: { organizationId?: string } }>(); + const organizationId = request.user?.organizationId; + + if (!organizationId) { + throw new UnauthorizedException('Organization is not selected'); + } + + await assertClinicOrganization(this.prisma, organizationId); + return true; + } +} diff --git a/backend/src/common/organization-type.ts b/backend/src/common/organization-type.ts new file mode 100644 index 0000000..d8b59ce --- /dev/null +++ b/backend/src/common/organization-type.ts @@ -0,0 +1,100 @@ +import { ForbiddenException, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; +import { ALL_TAB_PERMISSIONS, normalizeTabPermissions } from './permissions'; + +export type OrganizationTypeName = 'CLINIC' | 'LAB'; + +const CLINIC_ONLY_PERMISSIONS = new Set([ + 'TAB_PATIENTS_READ', + 'TAB_PATIENTS_EDIT', + 'TAB_APPOINTMENTS_READ', + 'TAB_APPOINTMENTS_EDIT', + 'TAB_TREATMENT_READ', + 'TAB_TREATMENT_EDIT', +]); + +const LAB_ONLY_PERMISSIONS = new Set(['TAB_CASES_READ', 'TAB_CASES_EDIT']); + +const SHARED_PERMISSIONS = ALL_TAB_PERMISSIONS.filter( + (p) => !CLINIC_ONLY_PERMISSIONS.has(p) && !LAB_ONLY_PERMISSIONS.has(p), +); + +export const CLINIC_TAB_PERMISSIONS = [ + ...SHARED_PERMISSIONS, + ...CLINIC_ONLY_PERMISSIONS, +] as const; + +export const LAB_TAB_PERMISSIONS = [ + ...SHARED_PERMISSIONS, + ...LAB_ONLY_PERMISSIONS, +] as const; + +const CLINIC_TAB_SET = new Set(CLINIC_TAB_PERMISSIONS); +const LAB_TAB_SET = new Set(LAB_TAB_PERMISSIONS); + +export function permissionsAllowedForOrgType(orgType: OrganizationTypeName): Set { + return orgType === 'LAB' ? LAB_TAB_SET : CLINIC_TAB_SET; +} + +export function filterPermissionsForOrgType( + names: string[], + orgType: OrganizationTypeName, +): string[] { + const allowed = permissionsAllowedForOrgType(orgType); + return normalizeTabPermissions(names.filter((n) => allowed.has(n))); +} + +export function ownerPermissionsForOrgType( + orgType: OrganizationTypeName, + hasActivePlan: boolean, +): string[] { + if (hasActivePlan) { + return orgType === 'LAB' ? [...LAB_TAB_PERMISSIONS] : [...CLINIC_TAB_PERMISSIONS]; + } + + const readOnly = (perms: readonly string[]) => + normalizeTabPermissions(perms.filter((p) => p.endsWith('_READ'))); + + return orgType === 'LAB' ? readOnly(LAB_TAB_PERMISSIONS) : readOnly(CLINIC_TAB_PERMISSIONS); +} + +export async function getOrganizationTypeName( + prisma: PrismaService, + organizationId: string, +): Promise { + const org = await prisma.organization.findUnique({ + where: { id: organizationId }, + select: { type: { select: { name: true } } }, + }); + + if (!org) { + throw new NotFoundException('Organization not found'); + } + + const name = org.type.name; + if (name !== 'CLINIC' && name !== 'LAB') { + throw new ForbiddenException('Unknown organization type'); + } + + return name; +} + +export async function assertClinicOrganization( + prisma: PrismaService, + organizationId: string, +): Promise { + const type = await getOrganizationTypeName(prisma, organizationId); + if (type !== 'CLINIC') { + throw new ForbiddenException('This action is only available for clinic organizations'); + } +} + +export async function assertLabOrganization( + prisma: PrismaService, + organizationId: string, +): Promise { + const type = await getOrganizationTypeName(prisma, organizationId); + if (type !== 'LAB') { + throw new ForbiddenException('This action is only available for lab organizations'); + } +} diff --git a/backend/src/common/permissions.ts b/backend/src/common/permissions.ts index 5d0b172..4a6f6c8 100644 --- a/backend/src/common/permissions.ts +++ b/backend/src/common/permissions.ts @@ -12,6 +12,8 @@ export const ALL_TAB_PERMISSIONS = [ 'TAB_APPOINTMENTS_EDIT', 'TAB_TREATMENT_READ', 'TAB_TREATMENT_EDIT', + 'TAB_CASES_READ', + 'TAB_CASES_EDIT', 'TAB_BILLING_READ', 'TAB_BILLING_EDIT', 'TAB_REPORTS_READ', @@ -40,6 +42,7 @@ const EDIT_TO_READ: Record = { TAB_STAFF_EDIT: 'TAB_STAFF_READ', TAB_ORGANIZATIONS_EDIT: 'TAB_ORGANIZATIONS_READ', TAB_TREATMENT_EDIT: 'TAB_TREATMENT_READ', + TAB_CASES_EDIT: 'TAB_CASES_READ', TAB_BILLING_EDIT: 'TAB_BILLING_READ', TAB_REPORTS_EDIT: 'TAB_REPORTS_READ', }; diff --git a/backend/src/modules/appointments/appointments.controller.ts b/backend/src/modules/appointments/appointments.controller.ts index f6dfa7f..12a50f2 100644 --- a/backend/src/modules/appointments/appointments.controller.ts +++ b/backend/src/modules/appointments/appointments.controller.ts @@ -11,6 +11,7 @@ import { UseGuards, } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { ClinicOrgGuard } from '../../common/guards/clinic-org.guard'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { AppointmentsService } from './appointments.service'; import { ColumnProvidersQueryDto } from './dto/column-providers-query.dto'; @@ -20,7 +21,7 @@ import { UpdateAppointmentDto } from './dto/update-appointment.dto'; @ApiTags('appointments') @ApiBearerAuth('JWT-auth') -@UseGuards(JwtAuthGuard) +@UseGuards(JwtAuthGuard, ClinicOrgGuard) @Controller('appointments') export class AppointmentsController { constructor(private readonly appointmentsService: AppointmentsService) {} diff --git a/backend/src/modules/appointments/appointments.module.ts b/backend/src/modules/appointments/appointments.module.ts index e6f0b5f..e51d71f 100644 --- a/backend/src/modules/appointments/appointments.module.ts +++ b/backend/src/modules/appointments/appointments.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; import { PrismaService } from '../../../prisma/prisma.service'; +import { ClinicOrgGuard } from '../../common/guards/clinic-org.guard'; import { StaffModule } from '../staff/staff.module'; import { AppointmentsController } from './appointments.controller'; import { AppointmentsService } from './appointments.service'; @@ -7,6 +8,6 @@ import { AppointmentsService } from './appointments.service'; @Module({ imports: [StaffModule], controllers: [AppointmentsController], - providers: [AppointmentsService, PrismaService], + providers: [AppointmentsService, PrismaService, ClinicOrgGuard], }) export class AppointmentsModule {} diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts index 17fcac4..5e1b861 100644 --- a/backend/src/modules/auth/auth.service.ts +++ b/backend/src/modules/auth/auth.service.ts @@ -20,6 +20,7 @@ import { UpdateLanguageDto, } from './dto/update-language.dto'; import { JwtPayload } from './interfaces/jwt-payload.interface'; +import { ownerPermissionsForOrgType, type OrganizationTypeName } from '../../common/organization-type'; const ALL_PERMISSIONS = [ 'TAB_TODAY_READ', @@ -34,6 +35,8 @@ const ALL_PERMISSIONS = [ 'TAB_APPOINTMENTS_EDIT', 'TAB_TREATMENT_READ', 'TAB_TREATMENT_EDIT', + 'TAB_CASES_READ', + 'TAB_CASES_EDIT', 'TAB_BILLING_READ', 'TAB_BILLING_EDIT', 'TAB_REPORTS_READ', @@ -806,11 +809,15 @@ export class AuthService { isOwner: boolean; organization: { plan?: { name: string; maxUsers: number; price: number } | null; + type?: { name: string }; }; permissions?: Array<{ permission: { name: string } }>; }): string[] { if (membership.isOwner) { - return membership.organization.plan ? ALL_PERMISSIONS : READ_ONLY_PERMISSIONS; + const orgType = (membership.organization.type?.name === 'LAB' + ? 'LAB' + : 'CLINIC') as OrganizationTypeName; + return ownerPermissionsForOrgType(orgType, Boolean(membership.organization.plan)); } return membership.permissions?.map((p) => p.permission.name) || []; } diff --git a/backend/src/modules/patients/patients.controller.ts b/backend/src/modules/patients/patients.controller.ts index 46be5c1..cd6fe86 100644 --- a/backend/src/modules/patients/patients.controller.ts +++ b/backend/src/modules/patients/patients.controller.ts @@ -10,6 +10,7 @@ import { UseGuards, } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { ClinicOrgGuard } from '../../common/guards/clinic-org.guard'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { CreatePatientDto } from './dto/create-patient.dto'; import { ListPatientsDto } from './dto/list-patients.dto'; @@ -18,7 +19,7 @@ import { PatientsService } from './patients.service'; @ApiTags('patients') @ApiBearerAuth('JWT-auth') -@UseGuards(JwtAuthGuard) +@UseGuards(JwtAuthGuard, ClinicOrgGuard) @Controller('patients') export class PatientsController { constructor(private readonly patientsService: PatientsService) {} diff --git a/backend/src/modules/patients/patients.module.ts b/backend/src/modules/patients/patients.module.ts index 514afd3..1f1a53d 100644 --- a/backend/src/modules/patients/patients.module.ts +++ b/backend/src/modules/patients/patients.module.ts @@ -1,10 +1,11 @@ import { Module } from '@nestjs/common'; import { PrismaService } from '../../../prisma/prisma.service'; +import { ClinicOrgGuard } from '../../common/guards/clinic-org.guard'; import { PatientsController } from './patients.controller'; import { PatientsService } from './patients.service'; @Module({ controllers: [PatientsController], - providers: [PatientsService, PrismaService], + providers: [PatientsService, PrismaService, ClinicOrgGuard], }) export class PatientsModule {} diff --git a/backend/src/modules/staff/staff.service.ts b/backend/src/modules/staff/staff.service.ts index 60f4b23..eb476b8 100644 --- a/backend/src/modules/staff/staff.service.ts +++ b/backend/src/modules/staff/staff.service.ts @@ -11,6 +11,10 @@ import { Prisma } from '@prisma/client'; import { PrismaService } from '../../../prisma/prisma.service'; import { AcceptStaffInviteDto } from './dto/accept-staff-invite.dto'; import { isUnlimitedSeats, normalizeTabPermissions } from '../../common/permissions'; +import { + filterPermissionsForOrgType, + getOrganizationTypeName, +} from '../../common/organization-type'; import { InviteStaffDto } from './dto/invite-staff.dto'; import { UpdateStaffMemberDto } from './dto/update-staff-member.dto'; @@ -96,7 +100,8 @@ export class StaffService { } const email = dto.email.trim().toLowerCase(); - const normalizedPerms = normalizeTabPermissions(dto.permissionNames); + const orgType = await getOrganizationTypeName(this.prisma, organizationId); + const normalizedPerms = filterPermissionsForOrgType(dto.permissionNames, orgType); const permissionRows = await this.prisma.permission.findMany({ where: { name: { in: normalizedPerms } }, @@ -371,7 +376,8 @@ export class StaffService { } if (dto.permissionNames !== undefined) { - const normalizedPerms = normalizeTabPermissions(dto.permissionNames); + const orgType = await getOrganizationTypeName(this.prisma, organizationId); + const normalizedPerms = filterPermissionsForOrgType(dto.permissionNames, orgType); const permissionRows = await this.prisma.permission.findMany({ where: { name: { in: normalizedPerms } }, select: { id: true, name: true }, diff --git a/backend/src/modules/treatments/treatments.controller.ts b/backend/src/modules/treatments/treatments.controller.ts index 6f9bdac..7293618 100644 --- a/backend/src/modules/treatments/treatments.controller.ts +++ b/backend/src/modules/treatments/treatments.controller.ts @@ -17,13 +17,14 @@ import { FilesInterceptor } from '@nestjs/platform-express'; import { ApiBearerAuth, ApiBody, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger'; import { memoryStorage } from 'multer'; import type { Response } from 'express'; +import { ClinicOrgGuard } from '../../common/guards/clinic-org.guard'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { SaveTreatmentDraftDto, SendTreatmentCaseDto } from './dto/treatment.dto'; import { TreatmentsService } from './treatments.service'; @ApiTags('treatments') @ApiBearerAuth('JWT-auth') -@UseGuards(JwtAuthGuard) +@UseGuards(JwtAuthGuard, ClinicOrgGuard) @Controller('treatments') export class TreatmentsController { constructor(private readonly treatmentsService: TreatmentsService) {} diff --git a/backend/src/modules/treatments/treatments.module.ts b/backend/src/modules/treatments/treatments.module.ts index 52fb3b6..47646b1 100644 --- a/backend/src/modules/treatments/treatments.module.ts +++ b/backend/src/modules/treatments/treatments.module.ts @@ -1,10 +1,11 @@ import { Module } from '@nestjs/common'; import { PrismaService } from '../../../prisma/prisma.service'; +import { ClinicOrgGuard } from '../../common/guards/clinic-org.guard'; import { TreatmentsController } from './treatments.controller'; import { TreatmentsService } from './treatments.service'; @Module({ controllers: [TreatmentsController], - providers: [TreatmentsService, PrismaService], + providers: [TreatmentsService, PrismaService, ClinicOrgGuard], }) export class TreatmentsModule {} diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 3ed3e7c..4cb3bf1 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -51,6 +51,7 @@ "patients": "Patients", "appointment": "Appointment", "treatment": "Treatment", + "cases": "Cases", "billing": "Billing", "reports": "Reports", "clinics": "Clinics", @@ -261,6 +262,7 @@ "featurePatients": "Patients", "featureAppointment": "Appointment", "featureTreatment": "Treatment", + "featureCases": "Cases", "featureBilling": "Billing", "featureReports": "Reports", "noTabAccess": "No tab access", @@ -313,6 +315,10 @@ "statusInactive": "Inactive", "emptyValue": "-" }, + "cases": { + "title": "Cases", + "stubDescription": "Received lab cases from linked clinics will appear here. Full inbox and task workflow coming in a later phase." + }, "appointments": { "title": "Appointments", "subtitle": "Search a patient, pick a date, then click a time slot under a provider to book.", diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index 3de25cc..31f11d1 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -51,6 +51,7 @@ "patients": "بیماران", "appointment": "نوبت‌ها", "treatment": "درمان", + "cases": "پرونده‌ها", "billing": "صورتحساب", "reports": "گزارش‌ها", "clinics": "کلینیک‌ها", @@ -261,6 +262,7 @@ "featurePatients": "بیماران", "featureAppointment": "نوبت‌ها", "featureTreatment": "درمان", + "featureCases": "پرونده‌ها", "featureBilling": "صورتحساب", "featureReports": "گزارش‌ها", "noTabAccess": "دسترسی به برگه‌ها وجود ندارد", @@ -313,6 +315,10 @@ "statusInactive": "غیرفعال", "emptyValue": "-" }, + "cases": { + "title": "پرونده‌ها", + "stubDescription": "پرونده‌های دریافتی از کلینیک‌های متصل به زودی اینجا نمایش داده می‌شوند. صندوق ورودی کامل و گردش کار وظایف در فاز بعدی اضافه می‌شود." + }, "appointments": { "title": "نوبت‌ها", "subtitle": "یک بیمار را جستجو کنید، تاریخ را انتخاب کنید، سپس روی یک زمان در زیر ارائه‌دهنده کلیک کنید تا رزرو کنید.", diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index 789e111..20ef812 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -51,6 +51,7 @@ "patients": "Patiënten", "appointment": "Afspraak", "treatment": "Behandeling", + "cases": "Dossiers", "billing": "Facturatie", "reports": "Rapporten", "clinics": "Klinieken", @@ -261,6 +262,7 @@ "featurePatients": "Patiënten", "featureAppointment": "Afspraak", "featureTreatment": "Behandeling", + "featureCases": "Dossiers", "featureBilling": "Facturatie", "featureReports": "Rapporten", "noTabAccess": "Geen tabbladtoegang", @@ -313,6 +315,10 @@ "statusInactive": "Inactief", "emptyValue": "-" }, + "cases": { + "title": "Dossiers", + "stubDescription": "Ontvangen labdossiers van gekoppelde klinieken verschijnen hier. Volledige inbox en takenworkflow volgen in een latere fase." + }, "appointments": { "title": "Afspraken", "subtitle": "Zoek een patiënt, kies een datum en klik vervolgens op een tijdslot onder een aanbieder om te boeken.", diff --git a/frontend/src/app/[locale]/(dashboard)/cases/page.tsx b/frontend/src/app/[locale]/(dashboard)/cases/page.tsx new file mode 100644 index 0000000..1495096 --- /dev/null +++ b/frontend/src/app/[locale]/(dashboard)/cases/page.tsx @@ -0,0 +1,14 @@ +'use client'; + +import { useTranslations } from 'next-intl'; + +export default function CasesPage() { + const t = useTranslations('cases'); + + return ( +

+

{t('title')}

+

{t('stubDescription')}

+
+ ); +} diff --git a/frontend/src/app/[locale]/(dashboard)/layout.tsx b/frontend/src/app/[locale]/(dashboard)/layout.tsx index d3b4c39..246abcd 100644 --- a/frontend/src/app/[locale]/(dashboard)/layout.tsx +++ b/frontend/src/app/[locale]/(dashboard)/layout.tsx @@ -8,10 +8,8 @@ import Sidebar from '@/components/ui/shared/Sidebar'; import { TopBarControls } from '@/components/ui/shared/TopBarControls'; import { DashboardAccountMenu } from '@/components/ui/dashboard/DashboardAccountMenu'; import { - canAccessAppointmentsSection, + canAccessDashboardRoute, firstAccessibleDashboardPath, - getRequiredReadPermissionForPath, - hasPermission, } from '@/components/shared/permissions'; export default function DashboardLayout({ children }: { children: React.ReactNode }) { @@ -33,15 +31,8 @@ export default function DashboardLayout({ children }: { children: React.ReactNod return; } - const required = getRequiredReadPermissionForPath(pathname); - if (required) { - const allowed = - hasPermission(currentOrganization, required) || - (required === 'TAB_APPOINTMENTS_READ' && - canAccessAppointmentsSection(currentOrganization)); - if (!allowed) { - router.replace(firstAccessibleDashboardPath(currentOrganization)); - } + if (!canAccessDashboardRoute(currentOrganization, pathname)) { + router.replace(firstAccessibleDashboardPath(currentOrganization)); } }, [isAuthReady, user, currentOrganization, router, pathname]); diff --git a/frontend/src/app/[locale]/(dashboard)/staff/page.tsx b/frontend/src/app/[locale]/(dashboard)/staff/page.tsx index 73a0171..e89a731 100644 --- a/frontend/src/app/[locale]/(dashboard)/staff/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/staff/page.tsx @@ -9,13 +9,13 @@ import { canViewStaff, } from '@/components/shared/permissions'; import { - STAFF_FEATURE_GROUPS, permissionNamesFromFeatureState, emptyFeaturePermissionState, featureStateFromPermissionNames, featureStateHasTreatmentEdit, resolveStaffFeatureLabel, formatAccessSummary, + staffFeatureGroupsForOrgType, type FeaturePermState, } from '@/components/staff/staff-permission-form'; import { @@ -112,7 +112,7 @@ function PermissionGrid({ return (
- {STAFF_FEATURE_GROUPS.map((g) => { + {staffFeatureGroupsForOrgType(organizationType).map((g) => { const cell = state[g.edit] ?? { read: false, edit: false }; return (
canEditStaff(currentOrganization), [currentOrganization]); const inviteHasTreatmentEdit = useMemo( - () => featureStateHasTreatmentEdit(invitePerms), - [invitePerms], + () => + currentOrganization?.type === 'CLINIC' && featureStateHasTreatmentEdit(invitePerms), + [currentOrganization?.type, invitePerms], + ); + const editHasTreatmentEdit = useMemo( + () => currentOrganization?.type === 'CLINIC' && featureStateHasTreatmentEdit(editPerms), + [currentOrganization?.type, editPerms], ); - const editHasTreatmentEdit = useMemo(() => featureStateHasTreatmentEdit(editPerms), [editPerms]); const hasActivePlan = Boolean(currentOrganization?.plan); const atSeatLimit = useMemo(() => { if (!seats || seats.unlimited) return false; @@ -309,7 +313,7 @@ export default function StaffPage() { setInviteStep(1); setInviteEmail(''); setInviteName(''); - setInvitePerms(emptyFeaturePermissionState()); + setInvitePerms(emptyFeaturePermissionState(currentOrganization?.type)); const defaults = createDefaultWorkingHoursState(); setInviteWorkingHoursDays(defaults.days); setInviteAutoRepeatWeekly(defaults.autoRepeatWeekly); @@ -393,7 +397,7 @@ export default function StaffPage() { setEditing(m); setEditStep(1); setEditName(m.name); - setEditPerms(featureStateFromPermissionNames(m.permissions ?? [])); + setEditPerms(featureStateFromPermissionNames(m.permissions ?? [], currentOrganization?.type)); setEditHoursValidationError(null); const defaults = createDefaultWorkingHoursState(); setEditWorkingHoursDays(defaults.days); diff --git a/frontend/src/components/shared/permissions.ts b/frontend/src/components/shared/permissions.ts index 210bf25..c35867a 100644 --- a/frontend/src/components/shared/permissions.ts +++ b/frontend/src/components/shared/permissions.ts @@ -1,16 +1,34 @@ import type { Organization } from '@/types/organization'; -const ROUTE_TAB_READ: { prefix: string; permission: string }[] = [ - { prefix: '/today', permission: 'TAB_TODAY_READ' }, - { prefix: '/staff', permission: 'TAB_STAFF_READ' }, - { prefix: '/organizations', permission: 'TAB_ORGANIZATIONS_READ' }, - { prefix: '/patients', permission: 'TAB_PATIENTS_READ' }, - { prefix: '/appointments', permission: 'TAB_APPOINTMENTS_READ' }, - { prefix: '/treatment', permission: 'TAB_TREATMENT_READ' }, - { prefix: '/billing', permission: 'TAB_BILLING_READ' }, - { prefix: '/reports', permission: 'TAB_REPORTS_READ' }, +export type OrgTypeName = 'CLINIC' | 'LAB'; + +export type DashboardRouteConfig = { + prefix: string; + permission: string; + orgTypes: OrgTypeName[]; +}; + +export const DASHBOARD_ROUTES: DashboardRouteConfig[] = [ + { prefix: '/today', permission: 'TAB_TODAY_READ', orgTypes: ['CLINIC', 'LAB'] }, + { prefix: '/staff', permission: 'TAB_STAFF_READ', orgTypes: ['CLINIC', 'LAB'] }, + { prefix: '/organizations', permission: 'TAB_ORGANIZATIONS_READ', orgTypes: ['CLINIC', 'LAB'] }, + { prefix: '/patients', permission: 'TAB_PATIENTS_READ', orgTypes: ['CLINIC'] }, + { prefix: '/appointments', permission: 'TAB_APPOINTMENTS_READ', orgTypes: ['CLINIC'] }, + { prefix: '/treatment', permission: 'TAB_TREATMENT_READ', orgTypes: ['CLINIC'] }, + { prefix: '/cases', permission: 'TAB_CASES_READ', orgTypes: ['LAB'] }, + { prefix: '/billing', permission: 'TAB_BILLING_READ', orgTypes: ['CLINIC', 'LAB'] }, + { prefix: '/reports', permission: 'TAB_REPORTS_READ', orgTypes: ['CLINIC', 'LAB'] }, ]; +export function isRouteAllowedForOrgType(pathname: string, orgType: OrgTypeName | undefined): boolean { + if (!orgType) return false; + const route = DASHBOARD_ROUTES.find( + (r) => pathname === r.prefix || pathname.startsWith(`${r.prefix}/`), + ); + if (!route) return true; + return route.orgTypes.includes(orgType); +} + export function hasPermission(org: Organization | null, permission: string): boolean { if (!org) return false; return Boolean(org.permissions?.includes(permission)); @@ -26,21 +44,49 @@ export function canViewTab(org: Organization | null, readPermission: string): bo return hasPermission(org, readPermission); } -export function getRequiredReadPermissionForPath(pathname: string): string | null { - for (const { prefix, permission } of ROUTE_TAB_READ) { - if (pathname === prefix || pathname.startsWith(`${prefix}/`)) { - return permission; +export function getRouteConfigForPath(pathname: string): DashboardRouteConfig | null { + for (const route of DASHBOARD_ROUTES) { + if (pathname === route.prefix || pathname.startsWith(`${route.prefix}/`)) { + return route; } } return null; } +export function getRequiredReadPermissionForPath(pathname: string): string | null { + return getRouteConfigForPath(pathname)?.permission ?? null; +} + +export function canAccessDashboardRoute(org: Organization | null, pathname: string): boolean { + if (!org) return false; + + const route = getRouteConfigForPath(pathname); + if (!route) return true; + + if (!isRouteAllowedForOrgType(pathname, org.type)) { + return false; + } + + if (route.prefix === '/appointments') { + return canAccessAppointmentsSection(org); + } + + return hasPermission(org, route.permission); +} + /** First dashboard route the user may open (ordered). Fallback: account settings. */ export function firstAccessibleDashboardPath(org: Organization | null): string { if (!org) return '/today'; - for (const { prefix, permission } of ROUTE_TAB_READ) { - if (hasPermission(org, permission)) return prefix; + + for (const route of DASHBOARD_ROUTES) { + if (!route.orgTypes.includes(org.type)) continue; + if (route.prefix === '/appointments') { + if (canAccessAppointmentsSection(org)) return route.prefix; + continue; + } + if (hasPermission(org, route.permission)) return route.prefix; } + return '/settings/account'; } @@ -62,6 +108,9 @@ export function canEditAppointments(org: Organization | null): boolean { if (!org) { return false; } + if (org.type !== 'CLINIC') { + return false; + } if (org.isOwner) { return true; } @@ -76,6 +125,9 @@ export function canAccessAppointmentsSection(org: Organization | null): boolean if (!org) { return false; } + if (org.type !== 'CLINIC') { + return false; + } if (org.isOwner) { return true; } @@ -90,6 +142,7 @@ export function canAccessAppointmentsSection(org: Organization | null): boolean /** Treatment composer, scheduling columns, and saving clinical workflows */ export function canEditTreatment(org: Organization | null): boolean { if (!org) return false; + if (org.type !== 'CLINIC') return false; if (org.isOwner) return true; return hasPermission(org, 'TAB_TREATMENT_EDIT'); } @@ -97,9 +150,28 @@ export function canEditTreatment(org: Organization | null): boolean { /** View treatment workspace (read-only or edit) */ export function canViewTreatment(org: Organization | null): boolean { if (!org) return false; + if (org.type !== 'CLINIC') return false; if (org.isOwner) return true; return ( hasPermission(org, 'TAB_TREATMENT_READ') || hasPermission(org, 'TAB_TREATMENT_EDIT') ); } + +/** Lab cases inbox */ +export function canViewCases(org: Organization | null): boolean { + if (!org) return false; + if (org.type !== 'LAB') return false; + if (org.isOwner) return true; + return ( + hasPermission(org, 'TAB_CASES_READ') || + hasPermission(org, 'TAB_CASES_EDIT') + ); +} + +export function canEditCases(org: Organization | null): boolean { + if (!org) return false; + if (org.type !== 'LAB') return false; + if (org.isOwner) return true; + return hasPermission(org, 'TAB_CASES_EDIT'); +} diff --git a/frontend/src/components/staff/staff-permission-form.ts b/frontend/src/components/staff/staff-permission-form.ts index a14ff02..e4312d7 100644 --- a/frontend/src/components/staff/staff-permission-form.ts +++ b/frontend/src/components/staff/staff-permission-form.ts @@ -3,22 +3,32 @@ * Add presentational pieces under ./components/ as the UI grows. */ +import type { OrgTypeName } from '@/components/shared/permissions'; + export const STAFF_FEATURE_GROUPS = [ - { labelKey: 'featureToday', read: 'TAB_TODAY_READ', edit: 'TAB_TODAY_EDIT' }, - { labelKey: 'featureStaff', read: 'TAB_STAFF_READ', edit: 'TAB_STAFF_EDIT' }, - { labelKey: 'featureOrganizations', read: 'TAB_ORGANIZATIONS_READ', edit: 'TAB_ORGANIZATIONS_EDIT' }, - { labelKey: 'featurePatients', read: 'TAB_PATIENTS_READ', edit: 'TAB_PATIENTS_EDIT' }, - { labelKey: 'featureAppointment', read: 'TAB_APPOINTMENTS_READ', edit: 'TAB_APPOINTMENTS_EDIT' }, - { labelKey: 'featureTreatment', read: 'TAB_TREATMENT_READ', edit: 'TAB_TREATMENT_EDIT' }, - { labelKey: 'featureBilling', read: 'TAB_BILLING_READ', edit: 'TAB_BILLING_EDIT' }, - { labelKey: 'featureReports', read: 'TAB_REPORTS_READ', edit: 'TAB_REPORTS_EDIT' }, + { labelKey: 'featureToday', read: 'TAB_TODAY_READ', edit: 'TAB_TODAY_EDIT', orgTypes: ['CLINIC', 'LAB'] as const }, + { labelKey: 'featureStaff', read: 'TAB_STAFF_READ', edit: 'TAB_STAFF_EDIT', orgTypes: ['CLINIC', 'LAB'] as const }, + { labelKey: 'featureOrganizations', read: 'TAB_ORGANIZATIONS_READ', edit: 'TAB_ORGANIZATIONS_EDIT', orgTypes: ['CLINIC', 'LAB'] as const }, + { labelKey: 'featurePatients', read: 'TAB_PATIENTS_READ', edit: 'TAB_PATIENTS_EDIT', orgTypes: ['CLINIC'] as const }, + { labelKey: 'featureAppointment', read: 'TAB_APPOINTMENTS_READ', edit: 'TAB_APPOINTMENTS_EDIT', orgTypes: ['CLINIC'] as const }, + { labelKey: 'featureTreatment', read: 'TAB_TREATMENT_READ', edit: 'TAB_TREATMENT_EDIT', orgTypes: ['CLINIC'] as const }, + { labelKey: 'featureCases', read: 'TAB_CASES_READ', edit: 'TAB_CASES_EDIT', orgTypes: ['LAB'] as const }, + { labelKey: 'featureBilling', read: 'TAB_BILLING_READ', edit: 'TAB_BILLING_EDIT', orgTypes: ['CLINIC', 'LAB'] as const }, + { labelKey: 'featureReports', read: 'TAB_REPORTS_READ', edit: 'TAB_REPORTS_EDIT', orgTypes: ['CLINIC', 'LAB'] as const }, ] as const; export type FeaturePermState = Record; -export type OrgType = 'CLINIC' | 'LAB' | null | undefined; +export type OrgType = OrgTypeName | null | undefined; type StaffFeaturesTranslate = (key: string) => string; +export function staffFeatureGroupsForOrgType(organizationType: OrgType) { + if (!organizationType) return [...STAFF_FEATURE_GROUPS]; + return STAFF_FEATURE_GROUPS.filter((g) => + (g.orgTypes as readonly OrgTypeName[]).includes(organizationType), + ); +} + export function resolveStaffFeatureLabel( group: (typeof STAFF_FEATURE_GROUPS)[number], organizationType: OrgType, @@ -30,18 +40,21 @@ export function resolveStaffFeatureLabel( return t(group.labelKey); } -export function emptyFeaturePermissionState(): FeaturePermState { +export function emptyFeaturePermissionState(organizationType?: OrgType): FeaturePermState { const s: FeaturePermState = {}; - for (const g of STAFF_FEATURE_GROUPS) { + for (const g of staffFeatureGroupsForOrgType(organizationType)) { s[g.edit] = { read: false, edit: false }; } return s; } -export function featureStateFromPermissionNames(names: string[]): FeaturePermState { +export function featureStateFromPermissionNames( + names: string[], + organizationType?: OrgType, +): FeaturePermState { const set = new Set(names); - const s = emptyFeaturePermissionState(); - for (const g of STAFF_FEATURE_GROUPS) { + const s = emptyFeaturePermissionState(organizationType); + for (const g of staffFeatureGroupsForOrgType(organizationType)) { const hasEdit = set.has(g.edit); const hasRead = set.has(g.read) || hasEdit; s[g.edit] = { read: hasRead, edit: hasEdit }; @@ -73,7 +86,7 @@ export function formatAccessSummary( if (!permissionNames?.length) return t('noTabAccess'); const set = new Set(permissionNames); const parts: string[] = []; - for (const g of STAFF_FEATURE_GROUPS) { + for (const g of staffFeatureGroupsForOrgType(organizationType)) { const hasEdit = set.has(g.edit); const hasRead = set.has(g.read) || hasEdit; if (!hasRead) continue; diff --git a/frontend/src/components/ui/shared/Sidebar.tsx b/frontend/src/components/ui/shared/Sidebar.tsx index 9faf4e5..d4e2e68 100644 --- a/frontend/src/components/ui/shared/Sidebar.tsx +++ b/frontend/src/components/ui/shared/Sidebar.tsx @@ -11,51 +11,73 @@ import { FlaskConical, FileText, CreditCard, + Package, } from 'lucide-react'; +import type { OrgTypeName } from '@/components/shared/permissions'; import { useAuth } from '@/lib/hooks/useAuth'; import { usePendingConnectionsCount } from '@/lib/hooks/usePendingConnectionsCount'; -import { canAccessAppointmentsSection, canViewTab } from '@/components/shared/permissions'; +import { + canAccessAppointmentsSection, + canViewCases, + canViewTab, +} from '@/components/shared/permissions'; import { counterpartOrganizationType, organizationTypeIcon, } from '@/components/shared/organizationTypeIcon'; +type MenuItem = { + name: string; + path: string; + icon: typeof LayoutDashboard; + read: string; + orgTypes: OrgTypeName[]; +}; + function Sidebar() { const t = useTranslations('nav'); const tCommon = useTranslations('common'); const pathname = usePathname(); const { currentOrganization } = useAuth(); const pendingConnectionsCount = usePendingConnectionsCount(); + const orgType = currentOrganization?.type; - - const menu = useMemo( - () => [ - { name: t('dashboard'), path: '/today', icon: LayoutDashboard, read: 'TAB_TODAY_READ' as const }, - { name: t('staff'), path: '/staff', icon: UserCog, read: 'TAB_STAFF_READ' as const }, + const menu = useMemo((): MenuItem[] => { + const items: MenuItem[] = [ + { name: t('dashboard'), path: '/today', icon: LayoutDashboard, read: 'TAB_TODAY_READ', orgTypes: ['CLINIC', 'LAB'] }, + { name: t('staff'), path: '/staff', icon: UserCog, read: 'TAB_STAFF_READ', orgTypes: ['CLINIC', 'LAB'] }, { - name: currentOrganization?.type === 'LAB' ? t('clinics') : t('labs'), + name: orgType === 'LAB' ? t('clinics') : t('labs'), path: '/organizations', - icon: organizationTypeIcon(counterpartOrganizationType(currentOrganization?.type)), - read: 'TAB_ORGANIZATIONS_READ' as const, + icon: organizationTypeIcon(counterpartOrganizationType(orgType)), + read: 'TAB_ORGANIZATIONS_READ', + orgTypes: ['CLINIC', 'LAB'], }, - { name: t('patients'), path: '/patients', icon: Users, read: 'TAB_PATIENTS_READ' as const }, - { name: t('appointment'), path: '/appointments', icon: Calendar, read: 'TAB_APPOINTMENTS_READ' as const }, - { name: t('treatment'), path: '/treatment', icon: FlaskConical, read: 'TAB_TREATMENT_READ' as const }, - { name: t('billing'), path: '/billing', icon: CreditCard, read: 'TAB_BILLING_READ' as const }, - { name: t('reports'), path: '/reports', icon: FileText, read: 'TAB_REPORTS_READ' as const }, - ], - [currentOrganization?.type, t], - ); + { name: t('patients'), path: '/patients', icon: Users, read: 'TAB_PATIENTS_READ', orgTypes: ['CLINIC'] }, + { name: t('cases'), path: '/cases', icon: Package, read: 'TAB_CASES_READ', orgTypes: ['LAB'] }, + { name: t('appointment'), path: '/appointments', icon: Calendar, read: 'TAB_APPOINTMENTS_READ', orgTypes: ['CLINIC'] }, + { name: t('treatment'), path: '/treatment', icon: FlaskConical, read: 'TAB_TREATMENT_READ', orgTypes: ['CLINIC'] }, + { name: t('billing'), path: '/billing', icon: CreditCard, read: 'TAB_BILLING_READ', orgTypes: ['CLINIC', 'LAB'] }, + { name: t('reports'), path: '/reports', icon: FileText, read: 'TAB_REPORTS_READ', orgTypes: ['CLINIC', 'LAB'] }, + ]; + return items; + }, [orgType, t]); const visibleMenu = useMemo( () => menu.filter((item) => { + if (!orgType || !item.orgTypes.includes(orgType)) { + return false; + } if (item.path === '/appointments') { return canAccessAppointmentsSection(currentOrganization); } + if (item.path === '/cases') { + return canViewCases(currentOrganization); + } return canViewTab(currentOrganization, item.read); }), - [currentOrganization, menu], + [currentOrganization, menu, orgType], ); return ( -- 2.53.0.windows.1 From 8b4ef6195dadd31a5e3516369f8076eb1294a0fc Mon Sep 17 00:00:00 2001 From: Admin Date: Sun, 28 Jun 2026 15:34:56 +0330 Subject: [PATCH 03/17] feature: Phase 2- splitting the treatment schema into TreatmentDetail and LabCase. --- .../migration.sql | 99 ++++ backend/prisma/schema.prisma | 84 ++-- .../modules/treatments/dto/treatment.dto.ts | 34 +- .../treatments/treatments.controller.ts | 67 ++- .../modules/treatments/treatments.service.ts | 448 +++++++++++++----- .../components/ui/treatment/CaseSentLabel.tsx | 17 +- .../ui/treatment/PastTreatmentsPanel.tsx | 2 +- .../ui/treatment/TreatmentPreviewCard.tsx | 10 +- .../ui/treatment/TreatmentPreviewDialog.tsx | 8 +- .../ui/treatment/TreatmentWorkspace.tsx | 81 +++- frontend/src/lib/api/treatments.ts | 58 ++- frontend/src/types/treatment.ts | 73 ++- 12 files changed, 749 insertions(+), 232 deletions(-) create mode 100644 backend/prisma/migrations/20260628140000_treatment_details_lab_cases/migration.sql diff --git a/backend/prisma/migrations/20260628140000_treatment_details_lab_cases/migration.sql b/backend/prisma/migrations/20260628140000_treatment_details_lab_cases/migration.sql new file mode 100644 index 0000000..d983434 --- /dev/null +++ b/backend/prisma/migrations/20260628140000_treatment_details_lab_cases/migration.sql @@ -0,0 +1,99 @@ +-- Split treatment_cases into treatment_details + lab_cases (test data cleared). + +DELETE FROM "treatment_case_sends"; +DELETE FROM "treatment_case_attachments"; +DELETE FROM "treatment_cases"; +DELETE FROM "treatments"; + +DROP TABLE IF EXISTS "treatment_case_sends"; +DROP TABLE IF EXISTS "treatment_case_attachments"; +DROP TABLE IF EXISTS "treatment_cases"; + +CREATE TABLE "treatment_details" ( + "id" TEXT NOT NULL, + "treatmentId" TEXT NOT NULL, + "clientKey" TEXT, + "sortOrder" INTEGER NOT NULL, + "treatmentType" TEXT NOT NULL, + "teeth" JSONB NOT NULL, + "comment" TEXT, + + CONSTRAINT "treatment_details_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "treatment_detail_attachments" ( + "id" TEXT NOT NULL, + "detailId" TEXT, + "appointmentId" TEXT, + "detailClientKey" TEXT, + "fileName" TEXT NOT NULL, + "mimeType" TEXT NOT NULL, + "sizeBytes" INTEGER NOT NULL, + "storagePath" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "treatment_detail_attachments_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "lab_cases" ( + "id" TEXT NOT NULL, + "treatmentId" TEXT NOT NULL, + "clientKey" TEXT, + "sortOrder" INTEGER NOT NULL, + "destinationOrganizationId" TEXT, + "labComment" TEXT, + "sentAt" TIMESTAMP(3), + + CONSTRAINT "lab_cases_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "lab_case_details" ( + "labCaseId" TEXT NOT NULL, + "treatmentDetailId" TEXT NOT NULL, + + CONSTRAINT "lab_case_details_pkey" PRIMARY KEY ("labCaseId", "treatmentDetailId") +); + +CREATE TABLE "lab_case_sends" ( + "id" TEXT NOT NULL, + "labCaseId" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "sentAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "lab_case_sends_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "treatment_details_treatmentId_sortOrder_idx" ON "treatment_details"("treatmentId", "sortOrder"); +CREATE INDEX "treatment_detail_attachments_appointmentId_detailClientKey_idx" ON "treatment_detail_attachments"("appointmentId", "detailClientKey"); +CREATE INDEX "treatment_detail_attachments_detailId_idx" ON "treatment_detail_attachments"("detailId"); +CREATE INDEX "lab_cases_treatmentId_sortOrder_idx" ON "lab_cases"("treatmentId", "sortOrder"); +CREATE UNIQUE INDEX "lab_case_details_treatmentDetailId_key" ON "lab_case_details"("treatmentDetailId"); +CREATE UNIQUE INDEX "lab_case_sends_labCaseId_organizationId_key" ON "lab_case_sends"("labCaseId", "organizationId"); + +ALTER TABLE "treatment_details" + ADD CONSTRAINT "treatment_details_treatmentId_fkey" + FOREIGN KEY ("treatmentId") REFERENCES "treatments"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "treatment_detail_attachments" + ADD CONSTRAINT "treatment_detail_attachments_detailId_fkey" + FOREIGN KEY ("detailId") REFERENCES "treatment_details"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "lab_cases" + ADD CONSTRAINT "lab_cases_treatmentId_fkey" + FOREIGN KEY ("treatmentId") REFERENCES "treatments"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "lab_case_details" + ADD CONSTRAINT "lab_case_details_labCaseId_fkey" + FOREIGN KEY ("labCaseId") REFERENCES "lab_cases"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "lab_case_details" + ADD CONSTRAINT "lab_case_details_treatmentDetailId_fkey" + FOREIGN KEY ("treatmentDetailId") REFERENCES "treatment_details"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "lab_case_sends" + ADD CONSTRAINT "lab_case_sends_labCaseId_fkey" + FOREIGN KEY ("labCaseId") REFERENCES "lab_cases"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "lab_case_sends" + ADD CONSTRAINT "lab_case_sends_organizationId_fkey" + FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 117d9e8..bf6f441 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -63,7 +63,7 @@ model Organization { createdPatients Patient[] @relation("PatientCreatedBy") appointments Appointment[] treatments Treatment[] - caseSends TreatmentCaseSend[] + labCaseSends LabCaseSend[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -131,7 +131,8 @@ model Treatment { organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) patient Patient @relation(fields: [patientId], references: [id], onDelete: Cascade) appointment Appointment? @relation(fields: [appointmentId], references: [id], onDelete: SetNull) - cases TreatmentCase[] + details TreatmentDetail[] + labCases LabCase[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -141,54 +142,81 @@ model Treatment { @@map("treatments") } -model TreatmentCase { - id String @id @default(uuid()) +model TreatmentDetail { + id String @id @default(uuid()) treatmentId String clientKey String? sortOrder Int treatmentType String teeth Json comment String? - sentAt DateTime? - treatment Treatment @relation(fields: [treatmentId], references: [id], onDelete: Cascade) - attachments TreatmentCaseAttachment[] - sends TreatmentCaseSend[] + treatment Treatment @relation(fields: [treatmentId], references: [id], onDelete: Cascade) + attachments TreatmentDetailAttachment[] + labCaseLink LabCaseDetail? @@index([treatmentId, sortOrder]) - @@map("treatment_cases") + @@map("treatment_details") } -model TreatmentCaseAttachment { - id String @id @default(uuid()) - caseId String? - appointmentId String? - caseClientKey String? - fileName String - mimeType String - sizeBytes Int - storagePath String +model TreatmentDetailAttachment { + id String @id @default(uuid()) + detailId String? + appointmentId String? + detailClientKey String? + fileName String + mimeType String + sizeBytes Int + storagePath String - case TreatmentCase? @relation(fields: [caseId], references: [id], onDelete: Cascade) + detail TreatmentDetail? @relation(fields: [detailId], references: [id], onDelete: Cascade) createdAt DateTime @default(now()) - @@index([appointmentId, caseClientKey]) - @@index([caseId]) - @@map("treatment_case_attachments") + @@index([appointmentId, detailClientKey]) + @@index([detailId]) + @@map("treatment_detail_attachments") } -model TreatmentCaseSend { +model LabCase { + id String @id @default(uuid()) + treatmentId String + clientKey String? + sortOrder Int + destinationOrganizationId String? + labComment String? + sentAt DateTime? + + treatment Treatment @relation(fields: [treatmentId], references: [id], onDelete: Cascade) + details LabCaseDetail[] + sends LabCaseSend[] + + @@index([treatmentId, sortOrder]) + @@map("lab_cases") +} + +model LabCaseDetail { + labCaseId String + treatmentDetailId String @unique + + labCase LabCase @relation(fields: [labCaseId], references: [id], onDelete: Cascade) + detail TreatmentDetail @relation(fields: [treatmentDetailId], references: [id], onDelete: Cascade) + + @@id([labCaseId, treatmentDetailId]) + @@map("lab_case_details") +} + +model LabCaseSend { id String @id @default(uuid()) - caseId String + labCaseId String organizationId String sentAt DateTime @default(now()) - case TreatmentCase @relation(fields: [caseId], references: [id], onDelete: Cascade) - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + labCase LabCase @relation(fields: [labCaseId], references: [id], onDelete: Cascade) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) - @@unique([caseId, organizationId]) - @@map("treatment_case_sends") + @@unique([labCaseId, organizationId]) + @@map("lab_case_sends") } model Plan { diff --git a/backend/src/modules/treatments/dto/treatment.dto.ts b/backend/src/modules/treatments/dto/treatment.dto.ts index 3d3c462..b61c3e7 100644 --- a/backend/src/modules/treatments/dto/treatment.dto.ts +++ b/backend/src/modules/treatments/dto/treatment.dto.ts @@ -12,7 +12,7 @@ import { Type } from 'class-transformer'; const TREATMENT_TYPES = ['consultation', 'filling', 'endo', 'visit', 'hygiene'] as const; -export class SaveTreatmentCaseDto { +export class SaveTreatmentDetailDto { @IsString() @MaxLength(64) clientId: string; @@ -43,15 +43,39 @@ export class SaveTreatmentDraftDto { @IsArray() @ArrayMinSize(1) @ValidateNested({ each: true }) - @Type(() => SaveTreatmentCaseDto) - cases: SaveTreatmentCaseDto[]; + @Type(() => SaveTreatmentDetailDto) + details: SaveTreatmentDetailDto[]; } -export class SendTreatmentCaseDto { +export class SaveLabCaseDto { + @IsString() + @MaxLength(64) + clientId: string; + + @IsOptional() + @IsUUID() + id?: string; + + @IsOptional() + @IsUUID() + destinationOrganizationId?: string; + + @IsOptional() + @IsString() + @MaxLength(5000) + labComment?: string; + @IsArray() @ArrayMinSize(1) @IsUUID(undefined, { each: true }) - organizationIds: string[]; + treatmentDetailIds: string[]; +} + +export class SaveTreatmentLabCasesDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => SaveLabCaseDto) + labCases: SaveLabCaseDto[]; } export class ListPatientTreatmentHistoryDto { diff --git a/backend/src/modules/treatments/treatments.controller.ts b/backend/src/modules/treatments/treatments.controller.ts index 7293618..5f91bba 100644 --- a/backend/src/modules/treatments/treatments.controller.ts +++ b/backend/src/modules/treatments/treatments.controller.ts @@ -19,7 +19,10 @@ import { memoryStorage } from 'multer'; import type { Response } from 'express'; import { ClinicOrgGuard } from '../../common/guards/clinic-org.guard'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; -import { SaveTreatmentDraftDto, SendTreatmentCaseDto } from './dto/treatment.dto'; +import { + SaveTreatmentDraftDto, + SaveTreatmentLabCasesDto, +} from './dto/treatment.dto'; import { TreatmentsService } from './treatments.service'; @ApiTags('treatments') @@ -67,7 +70,7 @@ export class TreatmentsController { } @Put('appointments/:appointmentId/draft') - @ApiOperation({ summary: 'Save draft treatment for an appointment (TAB_TREATMENT_EDIT)' }) + @ApiOperation({ summary: 'Save draft treatment details for an appointment (TAB_TREATMENT_EDIT)' }) saveDraft( @Param('appointmentId') appointmentId: string, @Body() dto: SaveTreatmentDraftDto, @@ -82,8 +85,24 @@ export class TreatmentsController { ); } - @Post('appointments/:appointmentId/cases/:caseClientKey/attachments') - @ApiOperation({ summary: 'Upload attachments for a draft case (TAB_TREATMENT_EDIT)' }) + @Put('appointments/:appointmentId/lab-cases') + @ApiOperation({ summary: 'Save lab case groupings for a draft treatment (TAB_TREATMENT_EDIT)' }) + saveLabCases( + @Param('appointmentId') appointmentId: string, + @Body() dto: SaveTreatmentLabCasesDto, + @Req() req: { user: { id: string; organizationId?: string } }, + ) { + const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user); + return this.treatmentsService.saveLabCasesForAppointment( + appointmentId, + dto, + organizationId, + req.user.id, + ); + } + + @Post('appointments/:appointmentId/details/:detailClientKey/attachments') + @ApiOperation({ summary: 'Upload attachments for a draft treatment detail (TAB_TREATMENT_EDIT)' }) @ApiConsumes('multipart/form-data') @ApiBody({ schema: { @@ -101,14 +120,39 @@ export class TreatmentsController { storage: memoryStorage(), }), ) - uploadAttachments( + uploadDetailAttachments( + @Param('appointmentId') appointmentId: string, + @Param('detailClientKey') detailClientKey: string, + @UploadedFiles() files: Express.Multer.File[], + @Req() req: { user: { id: string; organizationId?: string } }, + ) { + const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user); + return this.treatmentsService.uploadDetailAttachments( + appointmentId, + detailClientKey, + files, + organizationId, + req.user.id, + ); + } + + /** @deprecated Use details/:detailClientKey/attachments */ + @Post('appointments/:appointmentId/cases/:caseClientKey/attachments') + @ApiOperation({ summary: 'Legacy alias for detail attachment upload' }) + @ApiConsumes('multipart/form-data') + @UseInterceptors( + FilesInterceptor('files', 20, { + storage: memoryStorage(), + }), + ) + uploadDetailAttachmentsLegacy( @Param('appointmentId') appointmentId: string, @Param('caseClientKey') caseClientKey: string, @UploadedFiles() files: Express.Multer.File[], @Req() req: { user: { id: string; organizationId?: string } }, ) { const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user); - return this.treatmentsService.uploadCaseAttachments( + return this.treatmentsService.uploadDetailAttachments( appointmentId, caseClientKey, files, @@ -136,14 +180,13 @@ export class TreatmentsController { file.stream.pipe(res); } - @Post('cases/:caseId/send') - @ApiOperation({ summary: 'Send a treatment case to linked organizations (TAB_TREATMENT_EDIT)' }) - sendCase( - @Param('caseId') caseId: string, - @Body() dto: SendTreatmentCaseDto, + @Post('lab-cases/:labCaseId/send') + @ApiOperation({ summary: 'Send a lab case to its destination organization (TAB_TREATMENT_EDIT)' }) + sendLabCase( + @Param('labCaseId') labCaseId: string, @Req() req: { user: { id: string; organizationId?: string } }, ) { const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user); - return this.treatmentsService.sendCase(caseId, dto, organizationId, req.user.id); + return this.treatmentsService.sendLabCase(labCaseId, organizationId, req.user.id); } } diff --git a/backend/src/modules/treatments/treatments.service.ts b/backend/src/modules/treatments/treatments.service.ts index cf09b4f..b0cbe38 100644 --- a/backend/src/modules/treatments/treatments.service.ts +++ b/backend/src/modules/treatments/treatments.service.ts @@ -9,7 +9,10 @@ import { createReadStream, existsSync, mkdirSync } from 'fs'; import { join } from 'path'; import { randomUUID } from 'crypto'; import { PrismaService } from '../../../prisma/prisma.service'; -import { SaveTreatmentDraftDto, SendTreatmentCaseDto } from './dto/treatment.dto'; +import { + SaveTreatmentDraftDto, + SaveTreatmentLabCasesDto, +} from './dto/treatment.dto'; import { generateTreatmentTitle, isTreatmentType, @@ -18,10 +21,34 @@ import { } from './treatment.utils'; const treatmentInclude = { - cases: { + details: { orderBy: [{ sortOrder: 'asc' as const }], include: { attachments: { orderBy: [{ createdAt: 'asc' as const }] }, + labCaseLink: { + include: { + labCase: { + include: { + sends: { + orderBy: [{ sentAt: 'asc' as const }], + include: { organization: { select: { id: true, name: true } } }, + }, + }, + }, + }, + }, + }, + }, + labCases: { + orderBy: [{ sortOrder: 'asc' as const }], + include: { + details: { + include: { + detail: { + select: { id: true, clientKey: true, treatmentType: true, teeth: true }, + }, + }, + }, sends: { orderBy: [{ sentAt: 'asc' as const }], include: { organization: { select: { id: true, name: true } } }, @@ -80,7 +107,7 @@ export class TreatmentsService { limit = 20, ) { await this.assertCanReadTreatment(actorUserId, organizationId); - await this.ensurePatientInOrg(patientId, organizationId); + await this.ensurePatientExists(patientId); const items = await this.prisma.treatment.findMany({ where: { @@ -135,22 +162,22 @@ export class TreatmentsService { true, ); - for (const c of dto.cases) { - if (!isTreatmentType(c.treatmentType)) { - throw new BadRequestException(`Invalid treatment type: ${c.treatmentType}`); + for (const d of dto.details) { + if (!isTreatmentType(d.treatmentType)) { + throw new BadRequestException(`Invalid treatment type: ${d.treatmentType}`); } } - const normalizedCases = dto.cases.map((c, index) => ({ - ...c, + const normalizedDetails = dto.details.map((d, index) => ({ + ...d, sortOrder: index, - teeth: normalizeTeeth(c.teeth), - comment: c.comment?.trim() || null, - attachmentIds: c.attachmentIds ?? [], + teeth: normalizeTeeth(d.teeth), + comment: d.comment?.trim() || null, + attachmentIds: d.attachmentIds ?? [], })); const title = generateTreatmentTitle( - normalizedCases.map((c) => ({ treatmentType: c.treatmentType, teeth: c.teeth })), + normalizedDetails.map((d) => ({ treatmentType: d.treatmentType, teeth: d.teeth })), ); const treatment = await this.prisma.$transaction(async (tx) => { @@ -182,77 +209,80 @@ export class TreatmentsService { }, }); - const keepCaseIds = normalizedCases.map((c) => c.id).filter(Boolean) as string[]; - const existingCases = existing - ? await tx.treatmentCase.findMany({ + const keepDetailIds = normalizedDetails.map((d) => d.id).filter(Boolean) as string[]; + + const existingDetails = existing + ? await tx.treatmentDetail.findMany({ where: { treatmentId: saved.id }, - select: { id: true, sentAt: true }, + select: { id: true, labCaseLink: { select: { labCase: { select: { sentAt: true } } } } }, }) : []; - const sentCaseIds = new Set( - existingCases.filter((c) => c.sentAt).map((c) => c.id), + const lockedDetailIds = new Set( + existingDetails + .filter((d) => d.labCaseLink?.labCase.sentAt) + .map((d) => d.id), ); - const removableCaseIds = existingCases - .filter((c) => !keepCaseIds.includes(c.id) && !c.sentAt) - .map((c) => c.id); + const removableDetailIds = existingDetails + .filter((d) => !keepDetailIds.includes(d.id) && !lockedDetailIds.has(d.id)) + .map((d) => d.id); - if (removableCaseIds.length > 0) { - await tx.treatmentCase.deleteMany({ - where: { id: { in: removableCaseIds }, treatmentId: saved.id }, + if (removableDetailIds.length > 0) { + await tx.treatmentDetail.deleteMany({ + where: { id: { in: removableDetailIds }, treatmentId: saved.id }, }); } - for (const c of normalizedCases) { - if (c.id && sentCaseIds.has(c.id)) { + for (const d of normalizedDetails) { + if (d.id && lockedDetailIds.has(d.id)) { continue; } - const row = c.id - ? await tx.treatmentCase.update({ - where: { id: c.id }, + const row = d.id + ? await tx.treatmentDetail.update({ + where: { id: d.id }, data: { - clientKey: c.clientId, - sortOrder: c.sortOrder, - treatmentType: c.treatmentType, - teeth: c.teeth, - comment: c.comment, + clientKey: d.clientId, + sortOrder: d.sortOrder, + treatmentType: d.treatmentType, + teeth: d.teeth, + comment: d.comment, }, }) - : await tx.treatmentCase.create({ + : await tx.treatmentDetail.create({ data: { treatmentId: saved.id, - clientKey: c.clientId, - sortOrder: c.sortOrder, - treatmentType: c.treatmentType, - teeth: c.teeth, - comment: c.comment, + clientKey: d.clientId, + sortOrder: d.sortOrder, + treatmentType: d.treatmentType, + teeth: d.teeth, + comment: d.comment, }, }); - const allowedAttachmentIds = new Set(c.attachmentIds); - const pendingAttachments = await tx.treatmentCaseAttachment.findMany({ + const allowedAttachmentIds = new Set(d.attachmentIds); + const pendingAttachments = await tx.treatmentDetailAttachment.findMany({ where: { appointmentId: appointment.id, - caseClientKey: c.clientId, + detailClientKey: d.clientId, }, }); for (const attachment of pendingAttachments) { if (!allowedAttachmentIds.has(attachment.id)) { - await tx.treatmentCaseAttachment.delete({ where: { id: attachment.id } }); + await tx.treatmentDetailAttachment.delete({ where: { id: attachment.id } }); } else { - await tx.treatmentCaseAttachment.update({ + await tx.treatmentDetailAttachment.update({ where: { id: attachment.id }, - data: { caseId: row.id, appointmentId: null, caseClientKey: null }, + data: { detailId: row.id, appointmentId: null, detailClientKey: null }, }); } } - await tx.treatmentCaseAttachment.deleteMany({ + await tx.treatmentDetailAttachment.deleteMany({ where: { - caseId: row.id, + detailId: row.id, id: { notIn: [...allowedAttachmentIds] }, }, }); @@ -267,74 +297,191 @@ export class TreatmentsService { return { success: true, data: this.mapTreatment(treatment) }; } - async sendCase( - caseId: string, - dto: SendTreatmentCaseDto, + async saveLabCasesForAppointment( + appointmentId: string, + dto: SaveTreatmentLabCasesDto, + organizationId: string, + actorUserId: string, + ) { + await this.assertCanEditTreatment(actorUserId, organizationId); + const appointment = await this.ensureAppointmentProvider( + appointmentId, + organizationId, + actorUserId, + true, + ); + + const treatment = await this.prisma.treatment.findFirst({ + where: { appointmentId: appointment.id, organizationId, status: TreatmentStatus.DRAFT }, + select: { id: true }, + }); + + if (!treatment) { + throw new NotFoundException('Save treatment details before creating lab cases'); + } + + const detailIds = dto.labCases.flatMap((lc) => lc.treatmentDetailIds); + const uniqueDetailIds = new Set(detailIds); + if (uniqueDetailIds.size !== detailIds.length) { + throw new BadRequestException('Each treatment detail can belong to only one lab case'); + } + + const details = await this.prisma.treatmentDetail.findMany({ + where: { treatmentId: treatment.id, id: { in: detailIds } }, + select: { id: true }, + }); + if (details.length !== uniqueDetailIds.size) { + throw new BadRequestException('One or more treatment details were not found'); + } + + const linkedOrgIds = await this.getActiveLinkedOrganizationIds(organizationId); + + for (const lc of dto.labCases) { + if (lc.destinationOrganizationId && !linkedOrgIds.has(lc.destinationOrganizationId)) { + throw new BadRequestException('Destination organization is not an active linked counterpart'); + } + } + + const saved = await this.prisma.$transaction(async (tx) => { + const existingLabCases = await tx.labCase.findMany({ + where: { treatmentId: treatment.id }, + select: { id: true, sentAt: true }, + }); + + const sentLabCaseIds = new Set(existingLabCases.filter((lc) => lc.sentAt).map((lc) => lc.id)); + const keepLabCaseIds = dto.labCases.map((lc) => lc.id).filter(Boolean) as string[]; + + const removableLabCaseIds = existingLabCases + .filter((lc) => !keepLabCaseIds.includes(lc.id) && !lc.sentAt) + .map((lc) => lc.id); + + if (removableLabCaseIds.length > 0) { + await tx.labCase.deleteMany({ + where: { id: { in: removableLabCaseIds }, treatmentId: treatment.id }, + }); + } + + for (const [index, lc] of dto.labCases.entries()) { + if (lc.id && sentLabCaseIds.has(lc.id)) { + continue; + } + + const row = lc.id + ? await tx.labCase.update({ + where: { id: lc.id }, + data: { + clientKey: lc.clientId, + sortOrder: index, + destinationOrganizationId: lc.destinationOrganizationId ?? null, + labComment: lc.labComment?.trim() || null, + }, + }) + : await tx.labCase.create({ + data: { + treatmentId: treatment.id, + clientKey: lc.clientId, + sortOrder: index, + destinationOrganizationId: lc.destinationOrganizationId ?? null, + labComment: lc.labComment?.trim() || null, + }, + }); + + await tx.labCaseDetail.deleteMany({ where: { labCaseId: row.id } }); + await tx.labCaseDetail.createMany({ + data: lc.treatmentDetailIds.map((treatmentDetailId) => ({ + labCaseId: row.id, + treatmentDetailId, + })), + }); + } + + return tx.treatment.findUniqueOrThrow({ + where: { id: treatment.id }, + include: treatmentInclude, + }); + }); + + return { success: true, data: this.mapTreatment(saved) }; + } + + async sendLabCase( + labCaseId: string, organizationId: string, actorUserId: string, ) { await this.assertCanEditTreatment(actorUserId, organizationId); - const treatmentCase = await this.prisma.treatmentCase.findFirst({ + const labCase = await this.prisma.labCase.findFirst({ where: { - id: caseId, + id: labCaseId, treatment: { organizationId }, }, include: { - treatment: { select: { providerUserId: true, appointmentId: true } }, + treatment: { select: { providerUserId: true } }, sends: { select: { organizationId: true } }, + details: { select: { treatmentDetailId: true } }, }, }); - if (!treatmentCase) { - throw new NotFoundException('Treatment case not found'); + if (!labCase) { + throw new NotFoundException('Lab case not found'); } - if (treatmentCase.treatment.providerUserId !== actorUserId) { + if (!labCase.destinationOrganizationId) { + throw new BadRequestException('Lab case has no destination organization'); + } + + if (labCase.details.length === 0) { + throw new BadRequestException('Lab case must include at least one treatment detail'); + } + + if (labCase.treatment.providerUserId !== actorUserId) { const membership = await this.getMembership(actorUserId, organizationId); if (!membership?.isOwner) { - throw new ForbiddenException('Only the appointment provider can send this case'); + throw new ForbiddenException('Only the appointment provider can send this lab case'); } } const linkedOrgIds = await this.getActiveLinkedOrganizationIds(organizationId); - const uniqueTargets = [...new Set(dto.organizationIds)]; - - for (const orgId of uniqueTargets) { - if (!linkedOrgIds.has(orgId)) { - throw new BadRequestException('One or more organizations are not active linked counterparts'); - } + if (!linkedOrgIds.has(labCase.destinationOrganizationId)) { + throw new BadRequestException('Destination organization is not an active linked counterpart'); } - const alreadySent = new Set(treatmentCase.sends.map((s) => s.organizationId)); - const newTargets = uniqueTargets.filter((id) => !alreadySent.has(id)); - - if (newTargets.length === 0) { - throw new BadRequestException('Case was already sent to all selected organizations'); + const alreadySent = labCase.sends.some( + (s) => s.organizationId === labCase.destinationOrganizationId, + ); + if (alreadySent) { + throw new BadRequestException('Lab case was already sent to the destination organization'); } const now = new Date(); await this.prisma.$transaction(async (tx) => { - await tx.treatmentCaseSend.createMany({ - data: newTargets.map((organizationId) => ({ - caseId, - organizationId, - })), + await tx.labCaseSend.create({ + data: { + labCaseId, + organizationId: labCase.destinationOrganizationId!, + }, }); - if (!treatmentCase.sentAt) { - await tx.treatmentCase.update({ - where: { id: caseId }, + if (!labCase.sentAt) { + await tx.labCase.update({ + where: { id: labCaseId }, data: { sentAt: now }, }); } }); - const refreshed = await this.prisma.treatmentCase.findUniqueOrThrow({ - where: { id: caseId }, + const refreshed = await this.prisma.labCase.findUniqueOrThrow({ + where: { id: labCaseId }, include: { - attachments: { orderBy: [{ createdAt: 'asc' }] }, + details: { + include: { + detail: { + select: { id: true, clientKey: true, treatmentType: true, teeth: true }, + }, + }, + }, sends: { orderBy: [{ sentAt: 'asc' }], include: { organization: { select: { id: true, name: true } } }, @@ -342,12 +489,12 @@ export class TreatmentsService { }, }); - return { success: true, data: this.mapCase(refreshed) }; + return { success: true, data: this.mapLabCase(refreshed) }; } - async uploadCaseAttachments( + async uploadDetailAttachments( appointmentId: string, - caseClientKey: string, + detailClientKey: string, files: Express.Multer.File[], organizationId: string, actorUserId: string, @@ -355,8 +502,8 @@ export class TreatmentsService { await this.assertCanEditTreatment(actorUserId, organizationId); await this.ensureAppointmentProvider(appointmentId, organizationId, actorUserId, true); - if (!caseClientKey?.trim()) { - throw new BadRequestException('caseClientKey is required'); + if (!detailClientKey?.trim()) { + throw new BadRequestException('detailClientKey is required'); } if (!files?.length) { @@ -379,10 +526,10 @@ export class TreatmentsService { const { writeFileSync } = await import('fs'); writeFileSync(storagePath, file.buffer); - const attachment = await this.prisma.treatmentCaseAttachment.create({ + const attachment = await this.prisma.treatmentDetailAttachment.create({ data: { appointmentId, - caseClientKey, + detailClientKey, fileName: file.originalname, mimeType: file.mimetype || 'application/octet-stream', sizeBytes: file.size, @@ -403,16 +550,16 @@ export class TreatmentsService { ) { await this.assertCanReadTreatment(actorUserId, organizationId); - const attachment = await this.prisma.treatmentCaseAttachment.findFirst({ + const attachment = await this.prisma.treatmentDetailAttachment.findFirst({ where: { id: attachmentId, OR: [ - { case: { treatment: { organizationId } } }, + { detail: { treatment: { organizationId } } }, { appointmentId: { not: null } }, ], }, include: { - case: { select: { treatment: { select: { organizationId: true } } } }, + detail: { select: { treatment: { select: { organizationId: true } } } }, }, }); @@ -420,11 +567,11 @@ export class TreatmentsService { throw new NotFoundException('Attachment not found'); } - if (attachment.case && attachment.case.treatment.organizationId !== organizationId) { + if (attachment.detail && attachment.detail.treatment.organizationId !== organizationId) { throw new NotFoundException('Attachment not found'); } - if (!attachment.case && attachment.appointmentId) { + if (!attachment.detail && attachment.appointmentId) { const appointment = await this.prisma.appointment.findFirst({ where: { id: attachment.appointmentId, organizationId }, select: { id: true }, @@ -452,24 +599,51 @@ export class TreatmentsService { title: string; status: TreatmentStatus; treatmentAt: Date; - cases: Array<{ + details: Array<{ id: string; clientKey: string | null; treatmentType: string; teeth: unknown; comment: string | null; - sentAt: Date | null; attachments: Array<{ id: string; fileName: string; mimeType: string; sizeBytes: number; }>; - sends: Array<{ organizationId: string; sentAt: Date; organization: { id: string; name: string } }>; + labCaseLink?: { + labCase: { + id: string; + sentAt: Date | null; + destinationOrganizationId: string | null; + sends: Array<{ + organizationId: string; + sentAt: Date; + organization: { id: string; name: string }; + }>; + }; + } | null; + }>; + labCases: Array<{ + id: string; + clientKey: string | null; + sortOrder: number; + destinationOrganizationId: string | null; + labComment: string | null; + sentAt: Date | null; + details: Array<{ + treatmentDetailId: string; + detail: { id: string; clientKey: string | null; treatmentType: string; teeth: unknown }; + }>; + sends: Array<{ + organizationId: string; + sentAt: Date; + organization: { id: string; name: string }; + }>; }>; }) { - const documents = treatment.cases.flatMap((c) => - c.attachments.map((a) => this.mapAttachment(a)), + const documents = treatment.details.flatMap((d) => + d.attachments.map((a) => this.mapAttachment(a)), ); return { @@ -479,41 +653,93 @@ export class TreatmentsService { title: treatment.title, treatmentAt: treatment.treatmentAt.toISOString(), status: mapTreatmentStatusForApi(treatment.status), - cases: treatment.cases.map((c) => this.mapCase(c)), + details: treatment.details.map((d) => this.mapDetail(d)), + labCases: treatment.labCases.map((lc) => this.mapLabCase(lc)), documents, }; } - private mapCase(c: { + private mapDetail(d: { id: string; clientKey?: string | null; treatmentType: string; teeth: unknown; comment?: string | null; - sentAt?: Date | null; attachments?: Array<{ id: string; fileName: string; mimeType: string; sizeBytes: number; }>; - sends?: Array<{ organizationId: string; sentAt: Date; organization?: { id: string; name: string } }>; + labCaseLink?: { + labCase: { + id: string; + sentAt: Date | null; + destinationOrganizationId: string | null; + sends: Array<{ + organizationId: string; + sentAt: Date; + organization?: { id: string; name: string }; + }>; + }; + } | null; }) { + const labCase = d.labCaseLink?.labCase; return { - id: c.id, - clientId: c.clientKey ?? c.id, - treatmentType: c.treatmentType, - teeth: normalizeTeeth(c.teeth), - notes: c.comment ?? null, - sentAt: c.sentAt?.toISOString() ?? null, - sendToOrganizationIds: c.sends?.map((s) => s.organizationId) ?? [], + id: d.id, + clientId: d.clientKey ?? d.id, + treatmentType: d.treatmentType, + teeth: normalizeTeeth(d.teeth), + notes: d.comment ?? null, + attachmentMetas: (d.attachments ?? []).map((a) => this.mapAttachment(a)), + labCaseId: labCase?.id ?? null, + sentAt: labCase?.sentAt?.toISOString() ?? null, + destinationOrganizationId: labCase?.destinationOrganizationId ?? null, sends: - c.sends?.map((s) => ({ + labCase?.sends.map((s) => ({ + organizationId: s.organizationId, + organizationName: s.organization?.name ?? 'Unknown organization', + sentAt: s.sentAt.toISOString(), + })) ?? [], + }; + } + + private mapLabCase(lc: { + id: string; + clientKey?: string | null; + sortOrder?: number; + destinationOrganizationId?: string | null; + labComment?: string | null; + sentAt?: Date | null; + details?: Array<{ + treatmentDetailId: string; + detail?: { id: string; clientKey: string | null; treatmentType: string; teeth: unknown }; + }>; + sends?: Array<{ + organizationId: string; + sentAt: Date; + organization?: { id: string; name: string }; + }>; + }) { + return { + id: lc.id, + clientId: lc.clientKey ?? lc.id, + destinationOrganizationId: lc.destinationOrganizationId ?? null, + labComment: lc.labComment ?? null, + sentAt: lc.sentAt?.toISOString() ?? null, + treatmentDetailIds: lc.details?.map((d) => d.treatmentDetailId) ?? [], + details: (lc.details ?? []).map((d) => ({ + id: d.detail?.id ?? d.treatmentDetailId, + clientId: d.detail?.clientKey ?? d.treatmentDetailId, + treatmentType: d.detail?.treatmentType ?? '', + teeth: d.detail ? normalizeTeeth(d.detail.teeth) : [], + })), + sends: + lc.sends?.map((s) => ({ organizationId: s.organizationId, organizationName: s.organization?.name ?? 'Unknown organization', sentAt: s.sentAt.toISOString(), })) ?? [], - attachmentMetas: (c.attachments ?? []).map((a) => this.mapAttachment(a)), }; } @@ -549,7 +775,7 @@ export class TreatmentsService { ]); } - private async ensurePatientInOrg(patientId: string, _organizationId: string) { + private async ensurePatientExists(patientId: string) { const patient = await this.prisma.patient.findUnique({ where: { id: patientId }, select: { id: true }, diff --git a/frontend/src/components/ui/treatment/CaseSentLabel.tsx b/frontend/src/components/ui/treatment/CaseSentLabel.tsx index 281cbe6..f36df8a 100644 --- a/frontend/src/components/ui/treatment/CaseSentLabel.tsx +++ b/frontend/src/components/ui/treatment/CaseSentLabel.tsx @@ -2,21 +2,26 @@ import { useTranslations } from 'next-intl'; import { formatCaseSentLines } from '@/components/treatment/caseSendLabel'; -import type { LinkedOrganizationOption, PastTreatmentCase, TreatmentCaseDraft } from '@/types/treatment'; +import type { LabCaseSendInfo, LinkedOrganizationOption } from '@/types/treatment'; interface CaseSentLabelProps { - treatmentCase: Pick< - PastTreatmentCase | TreatmentCaseDraft, - 'sends' | 'sendToOrganizationIds' | 'sentAt' - >; + treatmentCase: { + sends?: LabCaseSendInfo[]; + sendToOrganizationIds?: string[]; + destinationOrganizationId?: string | null; + sentAt?: string | null; + }; orgs?: LinkedOrganizationOption[]; className?: string; } export function CaseSentLabel({ treatmentCase, orgs, className = 'text-xs text-text-muted' }: CaseSentLabelProps) { const t = useTranslations('treatment'); + const organizationIds = + treatmentCase.sendToOrganizationIds ?? + (treatmentCase.destinationOrganizationId ? [treatmentCase.destinationOrganizationId] : []); const lines = formatCaseSentLines(treatmentCase.sends, { - organizationIds: treatmentCase.sendToOrganizationIds ?? [], + organizationIds, sentAt: treatmentCase.sentAt ?? null, orgs, }, t); diff --git a/frontend/src/components/ui/treatment/PastTreatmentsPanel.tsx b/frontend/src/components/ui/treatment/PastTreatmentsPanel.tsx index 68031bf..e46997d 100644 --- a/frontend/src/components/ui/treatment/PastTreatmentsPanel.tsx +++ b/frontend/src/components/ui/treatment/PastTreatmentsPanel.tsx @@ -64,7 +64,7 @@ export function PastTreatmentsPanel({
- {treatment.cases.map((c, idx) => { + {treatment.details.map((c, idx) => { const attachments = c.attachmentMetas ?? []; const typeKey = TREATMENT_TYPE_KEYS[c.treatmentType as keyof typeof TREATMENT_TYPE_KEYS]; const typeLabel = typeKey ? t(typeKey) : c.treatmentType; diff --git a/frontend/src/components/ui/treatment/TreatmentPreviewCard.tsx b/frontend/src/components/ui/treatment/TreatmentPreviewCard.tsx index 27c51a9..9f8f425 100644 --- a/frontend/src/components/ui/treatment/TreatmentPreviewCard.tsx +++ b/frontend/src/components/ui/treatment/TreatmentPreviewCard.tsx @@ -22,7 +22,7 @@ export function TreatmentPreviewCard({ draft, disabled, onPreview }: TreatmentPr const t = useTranslations('treatment'); const attachmentCount = draft - ? draft.cases.reduce((n, c) => n + (c.attachmentMetas?.length ?? 0), 0) + ? draft.details.reduce((n, c) => n + (c.attachmentMetas?.length ?? 0), 0) : 0; return ( @@ -42,11 +42,11 @@ export function TreatmentPreviewCard({ draft, disabled, onPreview }: TreatmentPr {draft.status}

- {t('caseCount', { n: draft.cases.length })} ·{' '} + {t('caseCount', { n: draft.details.length })} ·{' '} {t('attachmentCount', { n: attachmentCount })}

- {draft.cases.slice(0, 2).map((c, idx) => { + {draft.details.slice(0, 2).map((c, idx) => { const typeKey = TREATMENT_TYPE_KEYS[c.treatmentType as keyof typeof TREATMENT_TYPE_KEYS]; const typeLabel = typeKey ? t(typeKey) : c.treatmentType; return ( @@ -65,8 +65,8 @@ export function TreatmentPreviewCard({ draft, disabled, onPreview }: TreatmentPr
); })} - {draft.cases.length > 2 && ( -

{t('moreCases', { n: draft.cases.length - 2 })}

+ {draft.details.length > 2 && ( +

{t('moreCases', { n: draft.details.length - 2 })}

)}
diff --git a/frontend/src/components/ui/treatment/TreatmentPreviewDialog.tsx b/frontend/src/components/ui/treatment/TreatmentPreviewDialog.tsx index 038b569..568a590 100644 --- a/frontend/src/components/ui/treatment/TreatmentPreviewDialog.tsx +++ b/frontend/src/components/ui/treatment/TreatmentPreviewDialog.tsx @@ -94,18 +94,20 @@ export function TreatmentPreviewDialog({ {t('statusLabel')} {treatment.status}

- {treatment.cases.length === 0 ? ( + {treatment.details.length === 0 ? (

{t('noCases')}

) : (
- {treatment.cases.map((c, idx) => { + {treatment.details.map((c, idx) => { const key = caseKey(c); const attachments = c.attachmentMetas ?? []; const latestAttachment = attachments.length > 0 ? attachments[attachments.length - 1] : null; const sent = Boolean(c.sentAt); const actionsEnabled = editable && !sent; - const selectedOrgIds = getCaseOrgIds?.(key) ?? c.sendToOrganizationIds ?? []; + const selectedOrgIds = + getCaseOrgIds?.(key) ?? + (c.destinationOrganizationId ? [c.destinationOrganizationId] : []); const sendExpanded = expandedSendCaseId === key; const comment = c.notes?.trim() ?? ''; const attachBusy = uploadBusyCaseId === key; diff --git a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx index 1fc9e66..265a427 100644 --- a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx +++ b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx @@ -71,17 +71,18 @@ function mapAppointment(record: AppointmentRecord): TreatmentAppointment { }; } -function mapCaseFromApi(c: PastTreatmentCase): TreatmentCaseDraft { +function mapDetailFromApi(d: PastTreatmentCase): TreatmentCaseDraft { return { - clientId: c.clientId, - id: c.id, - treatmentType: c.treatmentType, - teeth: c.teeth, - comment: c.notes ?? '', - attachmentMetas: c.attachmentMetas ?? [], - sendToOrganizationIds: c.sendToOrganizationIds ?? [], - sends: c.sends ?? [], - sentAt: c.sentAt ?? null, + clientId: d.clientId, + id: d.id, + treatmentType: d.treatmentType, + teeth: d.teeth, + comment: d.notes ?? '', + attachmentMetas: d.attachmentMetas ?? [], + labCaseId: d.labCaseId ?? null, + sendToOrganizationIds: d.destinationOrganizationId ? [d.destinationOrganizationId] : [], + sends: d.sends ?? [], + sentAt: d.sentAt ?? null, }; } @@ -110,16 +111,19 @@ function casesToPreviewTreatment( title: meta.title, treatmentAt: meta.treatmentAt, status: meta.status, - cases: cases.map((c, idx) => ({ + details: cases.map((c, idx) => ({ id: c.id ?? c.clientId ?? `draft-${idx + 1}`, clientId: c.clientId, treatmentType: c.treatmentType, teeth: c.teeth, notes: c.comment || null, attachmentMetas: c.attachmentMetas, - sendToOrganizationIds: c.sendToOrganizationIds, + labCaseId: c.labCaseId ?? null, + destinationOrganizationId: c.sendToOrganizationIds[0] ?? null, + sends: c.sends ?? [], sentAt: c.sentAt ?? null, })), + labCases: [], documents: [], }; } @@ -305,8 +309,8 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor const response = await treatmentsApi.getDraft(appointmentId); if (cancelled) return; - if (response.data?.cases?.length) { - const mapped = response.data.cases.map(mapCaseFromApi); + if (response.data?.details?.length) { + const mapped = response.data.details.map(mapDetailFromApi); setCases(mapped); setActiveCaseId((prev) => { const stillExists = mapped.some((c) => c.clientId === prev); @@ -387,7 +391,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor if (!selectedAppointment) throw new Error('No appointment selected'); const response = await treatmentsApi.saveDraft(selectedAppointment.id, { - cases: cases.map(({ clientId, id, treatmentType, teeth, comment, attachmentMetas }) => ({ + details: cases.map(({ clientId, id, treatmentType, teeth, comment, attachmentMetas }) => ({ clientId, id, treatmentType, @@ -396,7 +400,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor attachmentIds: attachmentMetas.map((a) => a.id), })), }); - const mapped = response.data.cases.map(mapCaseFromApi); + const mapped = response.data.details.map(mapDetailFromApi); setCases(mapped); setActiveCaseId((prev) => { const stillExists = mapped.some((c) => c.clientId === prev); @@ -422,28 +426,57 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor const handleSendCase = useCallback( async (treatmentCase: TreatmentCaseDraft) => { if (!canEditTreatmentForDay || !selectedAppointment) return; - const targets = treatmentCase.sendToOrganizationIds.filter((id) => + const destinationOrgId = treatmentCase.sendToOrganizationIds.find((id) => orgs.some((o) => o.id === id && o.active), ); - if (targets.length === 0) { + if (!destinationOrgId) { showError(t('errorChooseOrg')); return; } setSendBusyId(treatmentCase.clientId); try { const saved = await persistDraft(); - const serverCase = saved.cases.find((c) => c.clientId === treatmentCase.clientId); - if (!serverCase?.id) throw new Error(t('errorCaseMustSave')); + const serverDetail = saved.details.find((c) => c.clientId === treatmentCase.clientId); + if (!serverDetail?.id) throw new Error(t('errorCaseMustSave')); + + const labCaseClientId = treatmentCase.labCaseId + ? saved.labCases.find((lc) => lc.id === treatmentCase.labCaseId)?.clientId + : `lab-${treatmentCase.clientId}`; + + const existingLabCase = saved.labCases.find( + (lc) => + lc.treatmentDetailIds.includes(serverDetail.id) && + !lc.sentAt, + ); + + const withLabCases = await treatmentsApi.saveLabCases(selectedAppointment.id, { + labCases: [ + { + clientId: existingLabCase?.clientId ?? labCaseClientId ?? `lab-${treatmentCase.clientId}`, + id: existingLabCase?.id ?? treatmentCase.labCaseId ?? undefined, + destinationOrganizationId: destinationOrgId, + treatmentDetailIds: [serverDetail.id], + }, + ], + }); + + const labCase = withLabCases.data.labCases.find((lc) => + lc.treatmentDetailIds.includes(serverDetail.id), + ); + if (!labCase?.id) throw new Error(t('errorSendCase')); + + const response = await treatmentsApi.sendLabCase(labCase.id); - const response = await treatmentsApi.sendCase(serverCase.id, { organizationIds: targets }); setCases((prev) => { const next = prev.map((c) => c.clientId === treatmentCase.clientId ? { ...c, - id: response.data.id, + labCaseId: response.data.id, sentAt: response.data.sentAt, - sendToOrganizationIds: response.data.sendToOrganizationIds, + sendToOrganizationIds: response.data.destinationOrganizationId + ? [response.data.destinationOrganizationId] + : [], sends: response.data.sends, } : c, @@ -452,7 +485,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor return next; }); setRecentOrganizationIds((prev) => { - const next = [...targets.filter((id) => !prev.includes(id)), ...prev]; + const next = [destinationOrgId, ...prev.filter((id) => id !== destinationOrgId)]; return next.slice(0, 10); }); showSuccess(t('successCaseSent')); diff --git a/frontend/src/lib/api/treatments.ts b/frontend/src/lib/api/treatments.ts index eaea2b5..6dbb17d 100644 --- a/frontend/src/lib/api/treatments.ts +++ b/frontend/src/lib/api/treatments.ts @@ -1,11 +1,10 @@ import { apiClient } from './client'; import type { + LabCaseResponse, LinkedOrganizationOption, PastTreatment, - SaveTreatmentPayload, - SendTreatmentCasePayload, - TreatmentAttachmentMeta, - TreatmentCaseSendInfo, + SaveLabCasePayload, + SavedTreatmentDetailPayload, } from '@/types/treatment'; export const treatmentsApi = { @@ -33,34 +32,53 @@ export const treatmentsApi = { saveDraft: async ( appointmentId: string, - payload: Pick, + payload: { details: SavedTreatmentDetailPayload[] }, ): Promise<{ success: boolean; data: PastTreatment }> => { const response = await apiClient.put(`/treatments/appointments/${appointmentId}/draft`, payload); return response.data; }, - uploadCaseAttachments: async ( + saveLabCases: async ( appointmentId: string, - caseClientId: string, + payload: { labCases: SaveLabCasePayload[] }, + ): Promise<{ success: boolean; data: PastTreatment }> => { + const response = await apiClient.put( + `/treatments/appointments/${appointmentId}/lab-cases`, + payload, + ); + return response.data; + }, + + uploadDetailAttachments: async ( + appointmentId: string, + detailClientId: string, files: File[], - ): Promise<{ success: boolean; data: TreatmentAttachmentMeta[] }> => { + ): Promise<{ success: boolean; data: import('@/types/treatment').TreatmentAttachmentMeta[] }> => { const form = new FormData(); for (const file of files) { form.append('files', file); } const response = await apiClient.post( - `/treatments/appointments/${appointmentId}/cases/${encodeURIComponent(caseClientId)}/attachments`, + `/treatments/appointments/${appointmentId}/details/${encodeURIComponent(detailClientId)}/attachments`, form, { headers: { 'Content-Type': 'multipart/form-data' }, timeout: 120_000 }, ); return response.data; }, - sendCase: async ( - caseId: string, - payload: SendTreatmentCasePayload, - ): Promise<{ success: boolean; data: PastTreatmentCaseResponse }> => { - const response = await apiClient.post(`/treatments/cases/${caseId}/send`, payload); + /** @deprecated Use uploadDetailAttachments */ + uploadCaseAttachments: async ( + appointmentId: string, + detailClientId: string, + files: File[], + ) => { + return treatmentsApi.uploadDetailAttachments(appointmentId, detailClientId, files); + }, + + sendLabCase: async ( + labCaseId: string, + ): Promise<{ success: boolean; data: LabCaseResponse }> => { + const response = await apiClient.post(`/treatments/lab-cases/${labCaseId}/send`); return response.data; }, @@ -72,15 +90,3 @@ export const treatmentsApi = { return response.data; }, }; - -export interface PastTreatmentCaseResponse { - id: string; - clientId: string; - treatmentType: string; - teeth: string[]; - notes: string | null; - sentAt: string | null; - sendToOrganizationIds: string[]; - sends: TreatmentCaseSendInfo[]; - attachmentMetas: TreatmentAttachmentMeta[]; -} diff --git a/frontend/src/types/treatment.ts b/frontend/src/types/treatment.ts index 275f71f..ca00506 100644 --- a/frontend/src/types/treatment.ts +++ b/frontend/src/types/treatment.ts @@ -61,24 +61,47 @@ export const TREATMENT_TYPES = [ export type TreatmentType = (typeof TREATMENT_TYPES)[number]; -export interface TreatmentCaseSendInfo { +export interface LabCaseSendInfo { organizationId: string; organizationName: string; sentAt: string; } -export interface PastTreatmentCase { +/** @deprecated Use LabCaseSendInfo */ +export type TreatmentCaseSendInfo = LabCaseSendInfo; + +export interface PastTreatmentDetail { id: string; clientId: string; treatmentType: TreatmentType; teeth: FdiToothId[]; notes?: string | null; attachmentMetas?: TreatmentAttachmentMeta[]; - sendToOrganizationIds?: string[]; - sends?: TreatmentCaseSendInfo[]; + labCaseId?: string | null; + destinationOrganizationId?: string | null; + sends?: LabCaseSendInfo[]; sentAt?: string | null; } +/** @deprecated Use PastTreatmentDetail */ +export type PastTreatmentCase = PastTreatmentDetail; + +export interface PastLabCase { + id: string; + clientId: string; + destinationOrganizationId: string | null; + labComment?: string | null; + sentAt?: string | null; + treatmentDetailIds: string[]; + details: Array<{ + id: string; + clientId: string; + treatmentType: string; + teeth: FdiToothId[]; + }>; + sends?: LabCaseSendInfo[]; +} + export interface PastTreatment { id: string; patientId: string; @@ -86,7 +109,8 @@ export interface PastTreatment { title: string; treatmentAt: string; status: string; - cases: PastTreatmentCase[]; + details: PastTreatmentDetail[]; + labCases: PastLabCase[]; documents: TreatmentAttachmentMeta[]; } @@ -96,19 +120,23 @@ export interface LinkedOrganizationOption { active: boolean; } -export interface TreatmentCaseDraft { +export interface TreatmentDetailDraft { clientId: string; id?: string; treatmentType: TreatmentType; teeth: FdiToothId[]; comment: string; attachmentMetas: TreatmentAttachmentMeta[]; + labCaseId?: string | null; sendToOrganizationIds: string[]; - sends?: TreatmentCaseSendInfo[]; + sends?: LabCaseSendInfo[]; sentAt?: string | null; } -export type SavedTreatmentCasePayload = { +/** @deprecated Use TreatmentDetailDraft — kept for editor components until Phase 4 rename */ +export type TreatmentCaseDraft = TreatmentDetailDraft; + +export type SavedTreatmentDetailPayload = { clientId: string; id?: string; treatmentType: TreatmentType; @@ -117,12 +145,35 @@ export type SavedTreatmentCasePayload = { attachmentIds: string[]; }; +/** @deprecated Use SavedTreatmentDetailPayload */ +export type SavedTreatmentCasePayload = SavedTreatmentDetailPayload; + +export interface SaveLabCasePayload { + clientId: string; + id?: string; + destinationOrganizationId?: string; + labComment?: string; + treatmentDetailIds: string[]; +} + export interface SaveTreatmentPayload { appointmentId: string; patientId: string; - cases: SavedTreatmentCasePayload[]; + details: SavedTreatmentDetailPayload[]; } -export interface SendTreatmentCasePayload { - organizationIds: string[]; +export interface LabCaseResponse { + id: string; + clientId: string; + destinationOrganizationId: string | null; + labComment: string | null; + sentAt: string | null; + treatmentDetailIds: string[]; + details: Array<{ + id: string; + clientId: string; + treatmentType: string; + teeth: string[]; + }>; + sends: LabCaseSendInfo[]; } -- 2.53.0.windows.1 From 21f545ebdb36553154ee37d1df5176223b2ffbba Mon Sep 17 00:00:00 2001 From: Admin Date: Sun, 28 Jun 2026 16:46:42 +0330 Subject: [PATCH 04/17] feature: Phase3 - Task templates + generation on send --- .../migration.sql | 46 ++ .../migration.sql | 49 +++ backend/prisma/schema.prisma | 52 +++ backend/prisma/seed.ts | 52 +++ backend/src/app.module.ts | 4 + backend/src/common/guards/lab-org.guard.ts | 25 ++ .../appointments/appointments.service.ts | 5 + .../dto/create-appointment.dto.ts | 16 +- backend/src/modules/cases/cases.controller.ts | 56 +++ backend/src/modules/cases/cases.module.ts | 11 + backend/src/modules/cases/cases.service.ts | 405 ++++++++++++++++++ backend/src/modules/cases/dto/cases.dto.ts | 41 ++ .../modules/cases/lab-case-task.generator.ts | 91 ++++ .../treatment-catalog.controller.ts | 21 + .../treatment-catalog.module.ts | 12 + .../treatment-catalog.service.ts | 69 +++ .../modules/treatments/dto/treatment.dto.ts | 6 +- .../src/modules/treatments/treatment.utils.ts | 8 - .../modules/treatments/treatments.service.ts | 20 +- frontend/messages/en.json | 21 +- frontend/messages/fa.json | 21 +- frontend/messages/nl.json | 21 +- .../app/[locale]/(dashboard)/cases/page.tsx | 305 ++++++++++++- frontend/src/lib/api/cases.ts | 36 ++ frontend/src/types/cases.ts | 86 ++++ 25 files changed, 1448 insertions(+), 31 deletions(-) create mode 100644 backend/prisma/migrations/20260628150000_lab_case_tasks/migration.sql create mode 100644 backend/prisma/migrations/20260628160000_treatment_type_catalog/migration.sql create mode 100644 backend/src/common/guards/lab-org.guard.ts create mode 100644 backend/src/modules/cases/cases.controller.ts create mode 100644 backend/src/modules/cases/cases.module.ts create mode 100644 backend/src/modules/cases/cases.service.ts create mode 100644 backend/src/modules/cases/dto/cases.dto.ts create mode 100644 backend/src/modules/cases/lab-case-task.generator.ts create mode 100644 backend/src/modules/treatment-catalog/treatment-catalog.controller.ts create mode 100644 backend/src/modules/treatment-catalog/treatment-catalog.module.ts create mode 100644 backend/src/modules/treatment-catalog/treatment-catalog.service.ts create mode 100644 frontend/src/lib/api/cases.ts create mode 100644 frontend/src/types/cases.ts diff --git a/backend/prisma/migrations/20260628150000_lab_case_tasks/migration.sql b/backend/prisma/migrations/20260628150000_lab_case_tasks/migration.sql new file mode 100644 index 0000000..4fc9f0c --- /dev/null +++ b/backend/prisma/migrations/20260628150000_lab_case_tasks/migration.sql @@ -0,0 +1,46 @@ +-- Treatment workflow steps + lab case tasks + +CREATE TYPE "LabTaskStatus" AS ENUM ('PENDING', 'IN_PROGRESS', 'COMPLETED'); + +CREATE TABLE "treatment_workflow_steps" ( + "id" TEXT NOT NULL, + "treatmentType" TEXT NOT NULL, + "stepOrder" INTEGER NOT NULL, + "label" TEXT NOT NULL, + + CONSTRAINT "treatment_workflow_steps_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "treatment_workflow_steps_treatmentType_stepOrder_key" + ON "treatment_workflow_steps"("treatmentType", "stepOrder"); + +CREATE TABLE "lab_case_tasks" ( + "id" TEXT NOT NULL, + "labCaseId" TEXT NOT NULL, + "treatmentDetailId" TEXT NOT NULL, + "tooth" TEXT NOT NULL, + "treatmentType" TEXT NOT NULL, + "stepOrder" INTEGER NOT NULL, + "stepLabel" TEXT NOT NULL, + "assigneeUserId" TEXT, + "status" "LabTaskStatus" NOT NULL DEFAULT 'PENDING', + + CONSTRAINT "lab_case_tasks_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "lab_case_tasks_labCaseId_tooth_treatmentType_stepOrder_key" + ON "lab_case_tasks"("labCaseId", "tooth", "treatmentType", "stepOrder"); + +CREATE INDEX "lab_case_tasks_labCaseId_status_idx" ON "lab_case_tasks"("labCaseId", "status"); + +ALTER TABLE "lab_case_tasks" + ADD CONSTRAINT "lab_case_tasks_labCaseId_fkey" + FOREIGN KEY ("labCaseId") REFERENCES "lab_cases"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "lab_case_tasks" + ADD CONSTRAINT "lab_case_tasks_treatmentDetailId_fkey" + FOREIGN KEY ("treatmentDetailId") REFERENCES "treatment_details"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "lab_case_tasks" + ADD CONSTRAINT "lab_case_tasks_assigneeUserId_fkey" + FOREIGN KEY ("assigneeUserId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/backend/prisma/migrations/20260628160000_treatment_type_catalog/migration.sql b/backend/prisma/migrations/20260628160000_treatment_type_catalog/migration.sql new file mode 100644 index 0000000..9580aab --- /dev/null +++ b/backend/prisma/migrations/20260628160000_treatment_type_catalog/migration.sql @@ -0,0 +1,49 @@ +-- Treatment type catalog (data-driven; business logic reads from here) + +CREATE TABLE "treatment_types" ( + "id" TEXT NOT NULL, + "code" TEXT NOT NULL, + "labDependent" BOOLEAN NOT NULL DEFAULT false, + "sortOrder" INTEGER NOT NULL DEFAULT 0, + + CONSTRAINT "treatment_types_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "treatment_types_code_key" ON "treatment_types"("code"); + +-- Temporary catalog (will be replaced with 14 real-world types later) +INSERT INTO "treatment_types" ("id", "code", "labDependent", "sortOrder") VALUES + ('tt-consultation', 'consultation', false, 1), + ('tt-filling', 'filling', false, 2), + ('tt-endo', 'endo', true, 3), + ('tt-visit', 'visit', false, 4), + ('tt-hygiene', 'hygiene', false, 5); + +-- Re-link workflow steps to catalog rows +ALTER TABLE "treatment_workflow_steps" ADD COLUMN "treatmentTypeId" TEXT; + +UPDATE "treatment_workflow_steps" AS w +SET "treatmentTypeId" = t."id" +FROM "treatment_types" AS t +WHERE t."code" = w."treatmentType"; + +-- Drop steps for clinic-only types; only lab-dependent types keep workflows +DELETE FROM "treatment_workflow_steps" AS w +USING "treatment_types" AS t +WHERE w."treatmentTypeId" = t."id" AND t."labDependent" = false; + +DELETE FROM "treatment_workflow_steps" WHERE "treatmentTypeId" IS NULL; + +ALTER TABLE "treatment_workflow_steps" DROP CONSTRAINT IF EXISTS "treatment_workflow_steps_treatmentType_stepOrder_key"; +DROP INDEX IF EXISTS "treatment_workflow_steps_treatmentType_stepOrder_key"; + +ALTER TABLE "treatment_workflow_steps" DROP COLUMN "treatmentType"; + +ALTER TABLE "treatment_workflow_steps" ALTER COLUMN "treatmentTypeId" SET NOT NULL; + +ALTER TABLE "treatment_workflow_steps" + ADD CONSTRAINT "treatment_workflow_steps_treatmentTypeId_fkey" + FOREIGN KEY ("treatmentTypeId") REFERENCES "treatment_types"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +CREATE UNIQUE INDEX "treatment_workflow_steps_treatmentTypeId_stepOrder_key" + ON "treatment_workflow_steps"("treatmentTypeId", "stepOrder"); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index bf6f441..41f23ec 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -23,6 +23,7 @@ model User { sessions Session[] // 👈 ADD THIS - opposite relation for Session sentStaffInvites StaffInvitation[] sentOrganizationInvitations OrganizationInvitation[] + assignedLabCaseTasks LabCaseTask[] @relation("LabCaseTaskAssignee") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -118,6 +119,12 @@ enum TreatmentStatus { COMPLETED } +enum LabTaskStatus { + PENDING + IN_PROGRESS + COMPLETED +} + model Treatment { id String @id @default(uuid()) organizationId String @@ -154,6 +161,7 @@ model TreatmentDetail { treatment Treatment @relation(fields: [treatmentId], references: [id], onDelete: Cascade) attachments TreatmentDetailAttachment[] labCaseLink LabCaseDetail? + labCaseTasks LabCaseTask[] @@index([treatmentId, sortOrder]) @@map("treatment_details") @@ -190,6 +198,7 @@ model LabCase { treatment Treatment @relation(fields: [treatmentId], references: [id], onDelete: Cascade) details LabCaseDetail[] sends LabCaseSend[] + tasks LabCaseTask[] @@index([treatmentId, sortOrder]) @@map("lab_cases") @@ -219,6 +228,49 @@ model LabCaseSend { @@map("lab_case_sends") } +model TreatmentType { + id String @id @default(uuid()) + code String @unique + labDependent Boolean @default(false) + sortOrder Int @default(0) + + workflowSteps TreatmentWorkflowStep[] + + @@map("treatment_types") +} + +model TreatmentWorkflowStep { + id String @id @default(uuid()) + treatmentTypeId String + stepOrder Int + label String + + treatmentType TreatmentType @relation(fields: [treatmentTypeId], references: [id], onDelete: Cascade) + + @@unique([treatmentTypeId, stepOrder]) + @@map("treatment_workflow_steps") +} + +model LabCaseTask { + id String @id @default(uuid()) + labCaseId String + treatmentDetailId String + tooth String + treatmentType String + stepOrder Int + stepLabel String + assigneeUserId String? + status LabTaskStatus @default(PENDING) + + labCase LabCase @relation(fields: [labCaseId], references: [id], onDelete: Cascade) + detail TreatmentDetail @relation(fields: [treatmentDetailId], references: [id], onDelete: Cascade) + assignee User? @relation("LabCaseTaskAssignee", fields: [assigneeUserId], references: [id], onDelete: SetNull) + + @@unique([labCaseId, tooth, treatmentType, stepOrder]) + @@index([labCaseId, status]) + @@map("lab_case_tasks") +} + model Plan { id String @id @default(uuid()) name String @unique // "Solo", "Small", "Medium", "Large", "Enterprise" diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts index 171ac79..d111482 100644 --- a/backend/prisma/seed.ts +++ b/backend/prisma/seed.ts @@ -1,5 +1,6 @@ // backend/prisma/seed.ts import { PrismaClient } from '@prisma/client'; +import { randomUUID } from 'crypto'; import { config } from 'dotenv'; import path from 'path'; @@ -123,6 +124,57 @@ async function main() { } console.log('✅ Created features and permissions'); + const workflowSteps = [ + { code: 'endo', stepOrder: 1, label: 'Access review' }, + { code: 'endo', stepOrder: 2, label: 'Fabrication' }, + ] as const; + + const treatmentTypes = [ + { code: 'consultation', labDependent: false, sortOrder: 1 }, + { code: 'filling', labDependent: false, sortOrder: 2 }, + { code: 'endo', labDependent: true, sortOrder: 3 }, + { code: 'visit', labDependent: false, sortOrder: 4 }, + { code: 'hygiene', labDependent: false, sortOrder: 5 }, + ] as const; + + for (const type of treatmentTypes) { + await prisma.treatmentType.upsert({ + where: { code: type.code }, + update: { labDependent: type.labDependent, sortOrder: type.sortOrder }, + create: { + id: randomUUID(), + code: type.code, + labDependent: type.labDependent, + sortOrder: type.sortOrder, + }, + }); + } + console.log('✅ Seeded treatment type catalog'); + + for (const step of workflowSteps) { + const treatmentType = await prisma.treatmentType.findUniqueOrThrow({ + where: { code: step.code }, + select: { id: true }, + }); + + await prisma.treatmentWorkflowStep.upsert({ + where: { + treatmentTypeId_stepOrder: { + treatmentTypeId: treatmentType.id, + stepOrder: step.stepOrder, + }, + }, + update: { label: step.label }, + create: { + id: randomUUID(), + treatmentTypeId: treatmentType.id, + stepOrder: step.stepOrder, + label: step.label, + }, + }); + } + console.log('✅ Seeded lab workflow steps'); + console.log('🌱 Seeding completed successfully!'); } diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 70c3e1c..3b7d8ec 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -11,6 +11,8 @@ import { StaffModule } from './modules/staff/staff.module'; import { OrganizationModule } from './modules/organization/organization.module'; import { AppointmentsModule } from './modules/appointments/appointments.module'; import { TreatmentsModule } from './modules/treatments/treatments.module'; +import { CasesModule } from './modules/cases/cases.module'; +import { TreatmentCatalogModule } from './modules/treatment-catalog/treatment-catalog.module'; @Module({ imports: [ @@ -19,10 +21,12 @@ import { TreatmentsModule } from './modules/treatments/treatments.module'; load: [configurations], }), PrismaModule, // ✅ ADD THIS + TreatmentCatalogModule, AuthModule, PatientsModule, AppointmentsModule, TreatmentsModule, + CasesModule, StaffModule, OrganizationModule, AdminModule.forRoot(), diff --git a/backend/src/common/guards/lab-org.guard.ts b/backend/src/common/guards/lab-org.guard.ts new file mode 100644 index 0000000..69bc829 --- /dev/null +++ b/backend/src/common/guards/lab-org.guard.ts @@ -0,0 +1,25 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { PrismaService } from '../../../prisma/prisma.service'; +import { assertLabOrganization } from '../../common/organization-type'; + +@Injectable() +export class LabOrgGuard implements CanActivate { + constructor(private readonly prisma: PrismaService) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest<{ user?: { organizationId?: string } }>(); + const organizationId = request.user?.organizationId; + + if (!organizationId) { + throw new UnauthorizedException('Organization is not selected'); + } + + await assertLabOrganization(this.prisma, organizationId); + return true; + } +} diff --git a/backend/src/modules/appointments/appointments.service.ts b/backend/src/modules/appointments/appointments.service.ts index 769255e..ef1040e 100644 --- a/backend/src/modules/appointments/appointments.service.ts +++ b/backend/src/modules/appointments/appointments.service.ts @@ -14,6 +14,7 @@ import { StaffWorkingHoursService } from '../staff/staff-working-hours.service'; import { CreateAppointmentDto } from './dto/create-appointment.dto'; import { ListAppointmentsDto } from './dto/list-appointments.dto'; import { UpdateAppointmentDto } from './dto/update-appointment.dto'; +import { TreatmentCatalogService } from '../treatment-catalog/treatment-catalog.service'; const MS_PER_DAY = 86_400_000; @@ -22,6 +23,7 @@ export class AppointmentsService { constructor( private readonly prisma: PrismaService, private readonly staffWorkingHoursService: StaffWorkingHoursService, + private readonly treatmentCatalog: TreatmentCatalogService, ) {} getOrganizationIdFromUser(user: { organizationId?: string }) { @@ -134,6 +136,7 @@ export class AppointmentsService { await this.ensurePatientInOrg(dto.patientId, organizationId); await this.ensureProviderIsTreatmentEditor(dto.providerUserId, organizationId); + this.treatmentCatalog.assertKnownTreatmentType(dto.purpose); await this.ensureAppointmentWithinProviderWorkingHours( dto.providerUserId, organizationId, @@ -195,6 +198,8 @@ export class AppointmentsService { const providerUserId = dto.providerUserId ?? existing.providerUserId; const purpose = dto.purpose ?? existing.purpose; + this.treatmentCatalog.assertKnownTreatmentType(purpose); + await this.ensurePatientInOrg(patientId, organizationId); await this.ensureProviderIsTreatmentEditor(providerUserId, organizationId); await this.ensureAppointmentWithinProviderWorkingHours( diff --git a/backend/src/modules/appointments/dto/create-appointment.dto.ts b/backend/src/modules/appointments/dto/create-appointment.dto.ts index 19b293b..fb34434 100644 --- a/backend/src/modules/appointments/dto/create-appointment.dto.ts +++ b/backend/src/modules/appointments/dto/create-appointment.dto.ts @@ -1,9 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsDateString, IsIn, IsUUID } from 'class-validator'; - -const APPOINTMENT_PURPOSES = ['consultation', 'filling', 'endo', 'visit', 'hygiene'] as const; - -export type AppointmentPurpose = (typeof APPOINTMENT_PURPOSES)[number]; +import { IsDateString, IsString, IsUUID, MaxLength } from 'class-validator'; export class CreateAppointmentDto { @ApiProperty() @@ -22,7 +18,11 @@ export class CreateAppointmentDto { @IsDateString() endAt: string; - @ApiProperty({ enum: APPOINTMENT_PURPOSES }) - @IsIn([...APPOINTMENT_PURPOSES]) - purpose: AppointmentPurpose; + @ApiProperty({ + description: 'Treatment type code from the treatment catalog', + example: 'consultation', + }) + @IsString() + @MaxLength(64) + purpose: string; } diff --git a/backend/src/modules/cases/cases.controller.ts b/backend/src/modules/cases/cases.controller.ts new file mode 100644 index 0000000..2216e85 --- /dev/null +++ b/backend/src/modules/cases/cases.controller.ts @@ -0,0 +1,56 @@ +import { + Body, + Controller, + Get, + Param, + Patch, + Query, + Req, + UseGuards, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { LabOrgGuard } from '../../common/guards/lab-org.guard'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { CasesService } from './cases.service'; +import { ListLabCasesDto, UpdateLabCaseTaskDto } from './dto/cases.dto'; + +@ApiTags('cases') +@ApiBearerAuth('JWT-auth') +@UseGuards(JwtAuthGuard, LabOrgGuard) +@Controller('cases') +export class CasesController { + constructor(private readonly casesService: CasesService) {} + + @Get() + @ApiOperation({ summary: 'List lab cases received by this organization' }) + list(@Query() query: ListLabCasesDto, @Req() req) { + const organizationId = this.casesService.getOrganizationIdFromUser(req.user); + return this.casesService.list(organizationId, req.user.id, query); + } + + @Get('assignable-members') + @ApiOperation({ summary: 'List lab staff who can be assigned to tasks' }) + listAssignableMembers(@Req() req) { + const organizationId = this.casesService.getOrganizationIdFromUser(req.user); + return this.casesService.listAssignableMembers(organizationId, req.user.id); + } + + @Get(':id') + @ApiOperation({ summary: 'Get one lab case with tasks grouped by tooth' }) + getOne(@Param('id') id: string, @Req() req) { + const organizationId = this.casesService.getOrganizationIdFromUser(req.user); + return this.casesService.getOne(id, organizationId, req.user.id); + } + + @Patch(':id/tasks/:taskId') + @ApiOperation({ summary: 'Update task assignee or status' }) + updateTask( + @Param('id') id: string, + @Param('taskId') taskId: string, + @Body() dto: UpdateLabCaseTaskDto, + @Req() req, + ) { + const organizationId = this.casesService.getOrganizationIdFromUser(req.user); + return this.casesService.updateTask(id, taskId, dto, organizationId, req.user.id); + } +} diff --git a/backend/src/modules/cases/cases.module.ts b/backend/src/modules/cases/cases.module.ts new file mode 100644 index 0000000..6957773 --- /dev/null +++ b/backend/src/modules/cases/cases.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { PrismaService } from '../../../prisma/prisma.service'; +import { LabOrgGuard } from '../../common/guards/lab-org.guard'; +import { CasesController } from './cases.controller'; +import { CasesService } from './cases.service'; + +@Module({ + controllers: [CasesController], + providers: [CasesService, PrismaService, LabOrgGuard], +}) +export class CasesModule {} diff --git a/backend/src/modules/cases/cases.service.ts b/backend/src/modules/cases/cases.service.ts new file mode 100644 index 0000000..924ed55 --- /dev/null +++ b/backend/src/modules/cases/cases.service.ts @@ -0,0 +1,405 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { LabTaskStatus, Prisma } from '@prisma/client'; +import { PrismaService } from '../../../prisma/prisma.service'; +import { normalizeMobile } from '../../common/phone'; +import { TreatmentCatalogService } from '../treatment-catalog/treatment-catalog.service'; +import { normalizeTeeth } from '../treatments/treatment.utils'; +import { ListLabCasesDto, UpdateLabCaseTaskDto } from './dto/cases.dto'; + +const labCaseListInclude = { + treatment: { + include: { + organization: { select: { id: true, name: true } }, + patient: { select: { id: true, firstName: true, lastName: true, mobile: true } }, + appointment: { select: { startAt: true } }, + }, + }, + details: { + include: { + detail: { + select: { + id: true, + treatmentType: true, + teeth: true, + comment: true, + }, + }, + }, + }, + sends: { + orderBy: [{ sentAt: 'asc' as const }], + include: { organization: { select: { id: true, name: true } } }, + }, + tasks: { + orderBy: [ + { tooth: 'asc' as const }, + { treatmentType: 'asc' as const }, + { stepOrder: 'asc' as const }, + ], + include: { + assignee: { select: { id: true, name: true, email: true } }, + }, + }, +} satisfies Prisma.LabCaseInclude; + +@Injectable() +export class CasesService { + constructor( + private readonly prisma: PrismaService, + private readonly treatmentCatalog: TreatmentCatalogService, + ) {} + + getOrganizationIdFromUser(user: { organizationId?: string }) { + if (!user?.organizationId) { + throw new BadRequestException('Organization is not selected'); + } + return user.organizationId; + } + + async list(labOrganizationId: string, actorUserId: string, query: ListLabCasesDto) { + await this.assertCanReadCases(actorUserId, labOrganizationId); + + 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 = { + 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([ + 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 getOne(labCaseId: string, labOrganizationId: string, actorUserId: string) { + await this.assertCanReadCases(actorUserId, labOrganizationId); + + const labCase = await this.prisma.labCase.findFirst({ + where: { + id: labCaseId, + sentAt: { not: null }, + sends: { some: { organizationId: labOrganizationId } }, + }, + include: labCaseListInclude, + }); + + if (!labCase) { + throw new NotFoundException('Case not found'); + } + + return { success: true, data: this.mapLabCaseDetail(labCase) }; + } + + async updateTask( + labCaseId: string, + taskId: string, + dto: UpdateLabCaseTaskDto, + labOrganizationId: string, + actorUserId: string, + ) { + await this.assertCanEditCases(actorUserId, labOrganizationId); + + const task = await this.prisma.labCaseTask.findFirst({ + where: { + id: taskId, + labCaseId, + labCase: { + sentAt: { not: null }, + sends: { some: { organizationId: labOrganizationId } }, + }, + }, + }); + + if (!task) { + throw new NotFoundException('Task not found'); + } + + if (dto.assigneeUserId !== undefined && dto.assigneeUserId !== null) { + await this.ensureLabMember(dto.assigneeUserId, labOrganizationId); + } + + const updated = await this.prisma.labCaseTask.update({ + where: { id: taskId }, + data: { + ...(dto.assigneeUserId !== undefined ? { assigneeUserId: dto.assigneeUserId } : {}), + ...(dto.status !== undefined ? { status: dto.status } : {}), + }, + include: { + assignee: { select: { id: true, name: true, email: true } }, + }, + }); + + return { success: true, data: this.mapTask(updated) }; + } + + async listAssignableMembers(labOrganizationId: string, actorUserId: string) { + await this.assertCanReadCases(actorUserId, labOrganizationId); + + const memberships = await this.prisma.membership.findMany({ + where: { organizationId: labOrganizationId, isActive: true }, + include: { user: { select: { id: true, name: true, email: true } } }, + orderBy: [{ isOwner: 'desc' }, { createdAt: 'asc' }], + }); + + return { + success: true, + data: memberships.map((m) => ({ + userId: m.user.id, + name: m.user.name, + email: m.user.email, + isOwner: m.isOwner, + })), + }; + } + + private buildSearchWhere(q: string): Prisma.LabCaseWhereInput { + const orConditions: Prisma.LabCaseWhereInput[] = [ + { + treatment: { + patient: { + OR: [ + { firstName: { contains: q, mode: 'insensitive' } }, + { lastName: { contains: q, mode: 'insensitive' } }, + ], + }, + }, + }, + { + treatment: { + organization: { name: { contains: q, mode: 'insensitive' } }, + }, + }, + ]; + + const normalized = normalizeMobile(q); + if (normalized) { + orConditions.push({ + treatment: { patient: { mobile: normalized } }, + }); + } + + return { OR: orConditions }; + } + + private mapLabCaseListItem(lc: { + id: string; + sentAt: Date | null; + treatment: { + organization: { id: string; name: string }; + patient: { id: string; firstName: string; lastName: string; mobile: string }; + }; + details: Array<{ detail: { treatmentType: string } }>; + tasks: Array<{ id: string; status: LabTaskStatus }>; + }) { + const treatmentTypes = [...new Set(lc.details.map((d) => d.detail.treatmentType))]; + const completedTasks = lc.tasks.filter((t) => t.status === LabTaskStatus.COMPLETED).length; + + return { + id: lc.id, + sentAt: lc.sentAt?.toISOString() ?? null, + clinic: lc.treatment.organization, + patient: { + id: lc.treatment.patient.id, + firstName: lc.treatment.patient.firstName, + lastName: lc.treatment.patient.lastName, + mobile: lc.treatment.patient.mobile, + }, + treatmentTypes, + taskProgress: { + completed: completedTasks, + total: lc.tasks.length, + }, + }; + } + + private mapLabCaseDetail(lc: Prisma.LabCaseGetPayload<{ include: typeof labCaseListInclude }>) { + const treatmentTypes = [...new Set(lc.details.map((d) => d.detail.treatmentType))]; + const tasksByTooth = this.groupTasksByTooth(lc.tasks); + + return { + id: lc.id, + sentAt: lc.sentAt?.toISOString() ?? null, + labComment: lc.labComment, + clinic: lc.treatment.organization, + patient: lc.treatment.patient, + appointmentStartAt: lc.treatment.appointment?.startAt.toISOString() ?? null, + treatmentTypes, + details: lc.details.map((link) => ({ + id: link.detail.id, + treatmentType: link.detail.treatmentType, + teeth: normalizeTeeth(link.detail.teeth), + comment: link.detail.comment, + })), + sends: lc.sends.map((s) => ({ + organizationId: s.organizationId, + organizationName: s.organization.name, + sentAt: s.sentAt.toISOString(), + })), + tasks: lc.tasks.map((t) => this.mapTask(t)), + tasksByTooth, + taskProgress: { + completed: lc.tasks.filter((t) => t.status === LabTaskStatus.COMPLETED).length, + total: lc.tasks.length, + }, + }; + } + + private groupTasksByTooth( + tasks: Array<{ + id: string; + tooth: string; + treatmentType: string; + stepOrder: number; + stepLabel: string; + status: LabTaskStatus; + assigneeUserId: string | null; + assignee: { id: string; name: string; email: string } | null; + }>, + ) { + const groups = new Map< + string, + { + tooth: string; + treatmentType: string; + tasks: ReturnType[]; + } + >(); + + for (const task of tasks) { + const key = `${task.tooth}:${task.treatmentType}`; + const entry = groups.get(key) ?? { + tooth: task.tooth, + treatmentType: task.treatmentType, + tasks: [], + }; + entry.tasks.push(this.mapTask(task)); + groups.set(key, entry); + } + + return [...groups.values()]; + } + + private mapTask(task: { + id: string; + tooth: string; + treatmentType: string; + stepOrder: number; + stepLabel: string; + status: LabTaskStatus; + assigneeUserId: string | null; + assignee: { id: string; name: string; email: string } | null; + }) { + return { + id: task.id, + tooth: task.tooth, + treatmentType: task.treatmentType, + stepOrder: task.stepOrder, + stepLabel: task.stepLabel, + status: task.status, + assigneeUserId: task.assigneeUserId, + assignee: task.assignee + ? { id: task.assignee.id, name: task.assignee.name, email: task.assignee.email } + : null, + }; + } + + private async ensureLabMember(userId: string, labOrganizationId: string) { + const membership = await this.prisma.membership.findFirst({ + where: { userId, organizationId: labOrganizationId, isActive: true }, + select: { id: true }, + }); + if (!membership) { + throw new BadRequestException('Assignee must be an active member of this lab'); + } + } + + private async assertCanReadCases(userId: string, organizationId: string) { + const m = await this.getMembership(userId, organizationId); + if (!m) { + throw new ForbiddenException('You are not a member of this organization'); + } + if (m.isOwner) return; + const names = m.permissions.map((p) => p.permission.name); + if (names.includes('TAB_CASES_READ') || names.includes('TAB_CASES_EDIT')) { + return; + } + throw new ForbiddenException('You do not have access to cases'); + } + + private async assertCanEditCases(userId: string, organizationId: string) { + const m = await this.getMembership(userId, organizationId); + if (!m) { + throw new ForbiddenException('You are not a member of this organization'); + } + if (m.isOwner) return; + const names = m.permissions.map((p) => p.permission.name); + if (names.includes('TAB_CASES_EDIT')) { + return; + } + throw new ForbiddenException('You cannot update cases'); + } + + private async getMembership(userId: string, organizationId: string) { + return this.prisma.membership.findFirst({ + where: { userId, organizationId, isActive: true }, + include: { permissions: { include: { permission: true } } }, + }); + } +} diff --git a/backend/src/modules/cases/dto/cases.dto.ts b/backend/src/modules/cases/dto/cases.dto.ts new file mode 100644 index 0000000..331b798 --- /dev/null +++ b/backend/src/modules/cases/dto/cases.dto.ts @@ -0,0 +1,41 @@ +import { Transform } from 'class-transformer'; +import { IsEnum, IsInt, IsOptional, IsString, IsUUID, Max, Min, ValidateIf } from 'class-validator'; +import { LabTaskStatus } from '@prisma/client'; + +export class UpdateLabCaseTaskDto { + @IsOptional() + @ValidateIf((_, value) => value !== null) + @IsUUID() + assigneeUserId?: string | null; + + @IsOptional() + @IsEnum(LabTaskStatus) + status?: LabTaskStatus; +} + +export class ListLabCasesDto { + @IsOptional() + @IsString() + q?: string; + + @IsOptional() + @IsString() + clinicOrganizationId?: string; + + @IsOptional() + @IsString() + treatmentType?: string; + + @IsOptional() + @Transform(({ value }) => Number(value)) + @IsInt() + @Min(1) + page = 1; + + @IsOptional() + @Transform(({ value }) => Number(value)) + @IsInt() + @Min(1) + @Max(100) + limit = 20; +} \ No newline at end of file diff --git a/backend/src/modules/cases/lab-case-task.generator.ts b/backend/src/modules/cases/lab-case-task.generator.ts new file mode 100644 index 0000000..8474b03 --- /dev/null +++ b/backend/src/modules/cases/lab-case-task.generator.ts @@ -0,0 +1,91 @@ +import { LabTaskStatus, Prisma } from '@prisma/client'; +import { normalizeTeeth } from '../treatments/treatment.utils'; + +type TransactionClient = Prisma.TransactionClient; + +export async function generateLabCaseTasks( + tx: TransactionClient, + labCaseId: string, +): Promise { + const existingCount = await tx.labCaseTask.count({ where: { labCaseId } }); + if (existingCount > 0) { + return 0; + } + + const labCase = await tx.labCase.findUnique({ + where: { id: labCaseId }, + include: { + details: { + include: { + detail: { + select: { id: true, treatmentType: true, teeth: true }, + }, + }, + }, + }, + }); + + if (!labCase?.details.length) { + return 0; + } + + const treatmentTypeCodes = [...new Set(labCase.details.map((d) => d.detail.treatmentType))]; + + const labDependentTypes = await tx.treatmentType.findMany({ + where: { code: { in: treatmentTypeCodes }, labDependent: true }, + select: { id: true, code: true }, + }); + + if (labDependentTypes.length === 0) { + return 0; + } + + const labDependentCodes = new Set(labDependentTypes.map((t) => t.code)); + + const workflowSteps = await tx.treatmentWorkflowStep.findMany({ + where: { treatmentTypeId: { in: labDependentTypes.map((t) => t.id) } }, + orderBy: [{ treatmentTypeId: 'asc' }, { stepOrder: 'asc' }], + include: { treatmentType: { select: { code: true } } }, + }); + + const stepsByTypeCode = new Map(); + for (const step of workflowSteps) { + const code = step.treatmentType.code; + const list = stepsByTypeCode.get(code) ?? []; + list.push({ stepOrder: step.stepOrder, label: step.label }); + stepsByTypeCode.set(code, list); + } + + const taskRows: Prisma.LabCaseTaskCreateManyInput[] = []; + + for (const link of labCase.details) { + const detail = link.detail; + if (!labDependentCodes.has(detail.treatmentType)) { + continue; + } + + const teeth = normalizeTeeth(detail.teeth); + const typeSteps = stepsByTypeCode.get(detail.treatmentType) ?? []; + + for (const tooth of teeth) { + for (const step of typeSteps) { + taskRows.push({ + labCaseId, + treatmentDetailId: detail.id, + tooth, + treatmentType: detail.treatmentType, + stepOrder: step.stepOrder, + stepLabel: step.label, + status: LabTaskStatus.PENDING, + }); + } + } + } + + if (taskRows.length === 0) { + return 0; + } + + await tx.labCaseTask.createMany({ data: taskRows }); + return taskRows.length; +} diff --git a/backend/src/modules/treatment-catalog/treatment-catalog.controller.ts b/backend/src/modules/treatment-catalog/treatment-catalog.controller.ts new file mode 100644 index 0000000..7e26db5 --- /dev/null +++ b/backend/src/modules/treatment-catalog/treatment-catalog.controller.ts @@ -0,0 +1,21 @@ +import { Controller, Get, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { TreatmentCatalogService } from './treatment-catalog.service'; + +@ApiTags('treatment-catalog') +@ApiBearerAuth('JWT-auth') +@UseGuards(JwtAuthGuard) +@Controller('treatment-catalog') +export class TreatmentCatalogController { + constructor(private readonly treatmentCatalogService: TreatmentCatalogService) {} + + @Get() + @ApiOperation({ summary: 'List treatment types from the catalog (data-driven)' }) + list() { + return { + success: true, + data: this.treatmentCatalogService.list(), + }; + } +} diff --git a/backend/src/modules/treatment-catalog/treatment-catalog.module.ts b/backend/src/modules/treatment-catalog/treatment-catalog.module.ts new file mode 100644 index 0000000..10c4315 --- /dev/null +++ b/backend/src/modules/treatment-catalog/treatment-catalog.module.ts @@ -0,0 +1,12 @@ +import { Global, Module } from '@nestjs/common'; +import { PrismaService } from '../../../prisma/prisma.service'; +import { TreatmentCatalogController } from './treatment-catalog.controller'; +import { TreatmentCatalogService } from './treatment-catalog.service'; + +@Global() +@Module({ + controllers: [TreatmentCatalogController], + providers: [TreatmentCatalogService, PrismaService], + exports: [TreatmentCatalogService], +}) +export class TreatmentCatalogModule {} diff --git a/backend/src/modules/treatment-catalog/treatment-catalog.service.ts b/backend/src/modules/treatment-catalog/treatment-catalog.service.ts new file mode 100644 index 0000000..e6c8c9e --- /dev/null +++ b/backend/src/modules/treatment-catalog/treatment-catalog.service.ts @@ -0,0 +1,69 @@ +import { BadRequestException, Injectable, OnModuleInit } from '@nestjs/common'; +import { PrismaService } from '../../../prisma/prisma.service'; + +export type TreatmentTypeCatalogEntry = { + id: string; + code: string; + labDependent: boolean; + sortOrder: number; +}; + +@Injectable() +export class TreatmentCatalogService implements OnModuleInit { + private loaded = false; + private byCode = new Map(); + + constructor(private readonly prisma: PrismaService) {} + + async onModuleInit() { + await this.refresh(); + } + + async refresh(): Promise { + const rows = await this.prisma.treatmentType.findMany({ + orderBy: [{ sortOrder: 'asc' }, { code: 'asc' }], + select: { id: true, code: true, labDependent: true, sortOrder: true }, + }); + + this.byCode = new Map(rows.map((row) => [row.code, row])); + this.loaded = true; + } + + list(): TreatmentTypeCatalogEntry[] { + this.ensureLoaded(); + return [...this.byCode.values()]; + } + + getByCode(code: string): TreatmentTypeCatalogEntry | undefined { + this.ensureLoaded(); + return this.byCode.get(code); + } + + assertKnownTreatmentType(code: string): TreatmentTypeCatalogEntry { + const entry = this.getByCode(code); + if (!entry) { + throw new BadRequestException(`Unknown treatment type: ${code}`); + } + return entry; + } + + assertLabDependentTreatmentType(code: string): TreatmentTypeCatalogEntry { + const entry = this.assertKnownTreatmentType(code); + if (!entry.labDependent) { + throw new BadRequestException( + `Treatment type "${code}" is completed in the clinic and cannot be sent to a lab`, + ); + } + return entry; + } + + isLabDependent(code: string): boolean { + return this.getByCode(code)?.labDependent ?? false; + } + + private ensureLoaded() { + if (!this.loaded) { + throw new Error('Treatment catalog is not loaded yet'); + } + } +} diff --git a/backend/src/modules/treatments/dto/treatment.dto.ts b/backend/src/modules/treatments/dto/treatment.dto.ts index b61c3e7..3c0312e 100644 --- a/backend/src/modules/treatments/dto/treatment.dto.ts +++ b/backend/src/modules/treatments/dto/treatment.dto.ts @@ -1,7 +1,6 @@ import { ArrayMinSize, IsArray, - IsIn, IsOptional, IsString, IsUUID, @@ -10,8 +9,6 @@ import { } from 'class-validator'; import { Type } from 'class-transformer'; -const TREATMENT_TYPES = ['consultation', 'filling', 'endo', 'visit', 'hygiene'] as const; - export class SaveTreatmentDetailDto { @IsString() @MaxLength(64) @@ -21,7 +18,8 @@ export class SaveTreatmentDetailDto { @IsUUID() id?: string; - @IsIn(TREATMENT_TYPES) + @IsString() + @MaxLength(64) treatmentType: string; @IsArray() diff --git a/backend/src/modules/treatments/treatment.utils.ts b/backend/src/modules/treatments/treatment.utils.ts index fc13721..6943064 100644 --- a/backend/src/modules/treatments/treatment.utils.ts +++ b/backend/src/modules/treatments/treatment.utils.ts @@ -1,13 +1,5 @@ import { TreatmentStatus } from '@prisma/client'; -const TREATMENT_TYPES = ['consultation', 'filling', 'endo', 'visit', 'hygiene'] as const; - -export type TreatmentTypeValue = (typeof TREATMENT_TYPES)[number]; - -export function isTreatmentType(value: string): value is TreatmentTypeValue { - return (TREATMENT_TYPES as readonly string[]).includes(value); -} - const FDI_TOOTH_IDS = new Set([ '11', '12', '13', '14', '15', '16', '17', '18', '21', '22', '23', '24', '25', '26', '27', '28', diff --git a/backend/src/modules/treatments/treatments.service.ts b/backend/src/modules/treatments/treatments.service.ts index b0cbe38..99b7846 100644 --- a/backend/src/modules/treatments/treatments.service.ts +++ b/backend/src/modules/treatments/treatments.service.ts @@ -9,13 +9,14 @@ import { createReadStream, existsSync, mkdirSync } from 'fs'; import { join } from 'path'; import { randomUUID } from 'crypto'; import { PrismaService } from '../../../prisma/prisma.service'; +import { generateLabCaseTasks } from '../cases/lab-case-task.generator'; +import { TreatmentCatalogService } from '../treatment-catalog/treatment-catalog.service'; import { SaveTreatmentDraftDto, SaveTreatmentLabCasesDto, } from './dto/treatment.dto'; import { generateTreatmentTitle, - isTreatmentType, mapTreatmentStatusForApi, normalizeTeeth, } from './treatment.utils'; @@ -61,7 +62,10 @@ const treatmentInclude = { export class TreatmentsService { private readonly uploadRoot = join(process.cwd(), 'uploads', 'treatments'); - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly treatmentCatalog: TreatmentCatalogService, + ) {} getOrganizationIdFromUser(user: { organizationId?: string }) { if (!user?.organizationId) { @@ -163,9 +167,7 @@ export class TreatmentsService { ); for (const d of dto.details) { - if (!isTreatmentType(d.treatmentType)) { - throw new BadRequestException(`Invalid treatment type: ${d.treatmentType}`); - } + this.treatmentCatalog.assertKnownTreatmentType(d.treatmentType); } const normalizedDetails = dto.details.map((d, index) => ({ @@ -328,12 +330,16 @@ export class TreatmentsService { const details = await this.prisma.treatmentDetail.findMany({ where: { treatmentId: treatment.id, id: { in: detailIds } }, - select: { id: true }, + select: { id: true, treatmentType: true }, }); if (details.length !== uniqueDetailIds.size) { throw new BadRequestException('One or more treatment details were not found'); } + for (const detail of details) { + this.treatmentCatalog.assertLabDependentTreatmentType(detail.treatmentType); + } + const linkedOrgIds = await this.getActiveLinkedOrganizationIds(organizationId); for (const lc of dto.labCases) { @@ -470,6 +476,8 @@ export class TreatmentsService { data: { sentAt: now }, }); } + + await generateLabCaseTasks(tx, labCaseId); }); const refreshed = await this.prisma.labCase.findUniqueOrThrow({ diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 4cb3bf1..05c96b0 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -317,7 +317,26 @@ }, "cases": { "title": "Cases", - "stubDescription": "Received lab cases from linked clinics will appear here. Full inbox and task workflow coming in a later phase." + "subtitle": "Lab cases sent from linked clinics. Assign tasks and track progress by tooth.", + "searchPlaceholder": "Search by patient name or mobile…", + "emptyList": "No cases received yet.", + "selectCaseHint": "Select a case from the list to view tasks.", + "fromClinic": "From {name}", + "sentAt": "Sent {date}", + "taskProgressLabel": "Tasks: {completed} of {total} completed", + "taskProgressShort": "{progress} tasks", + "treatmentDetails": "Treatment details", + "teethLabel": "Teeth", + "tasksByTooth": "Tasks by tooth", + "toothGroupTitle": "Tooth {tooth} · {type}", + "noTasks": "No tasks were generated for this case.", + "unassigned": "Unassigned", + "statusPending": "Pending", + "statusInProgress": "In progress", + "statusCompleted": "Completed", + "errorLoadList": "Failed to load cases.", + "errorLoadDetail": "Failed to load case details.", + "errorUpdateTask": "Failed to update task." }, "appointments": { "title": "Appointments", diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index 31f11d1..4315638 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -317,7 +317,26 @@ }, "cases": { "title": "پرونده‌ها", - "stubDescription": "پرونده‌های دریافتی از کلینیک‌های متصل به زودی اینجا نمایش داده می‌شوند. صندوق ورودی کامل و گردش کار وظایف در فاز بعدی اضافه می‌شود." + "subtitle": "پرونده‌های ارسالی از کلینیک‌های متصل. وظایف را تخصیص دهید و پیشرفت هر دندان را پیگیری کنید.", + "searchPlaceholder": "جستجو با نام یا موبایل بیمار…", + "emptyList": "هنوز پرونده‌ای دریافت نشده است.", + "selectCaseHint": "برای مشاهده وظایف، یک پرونده از فهرست انتخاب کنید.", + "fromClinic": "از {name}", + "sentAt": "ارسال {date}", + "taskProgressLabel": "وظایف: {completed} از {total} انجام شده", + "taskProgressShort": "{progress} وظیفه", + "treatmentDetails": "جزئیات درمان", + "teethLabel": "دندان‌ها", + "tasksByTooth": "وظایف به تفکیک دندان", + "toothGroupTitle": "دندان {tooth} · {type}", + "noTasks": "برای این پرونده وظیفه‌ای ایجاد نشده است.", + "unassigned": "بدون مسئول", + "statusPending": "در انتظار", + "statusInProgress": "در حال انجام", + "statusCompleted": "انجام شده", + "errorLoadList": "بارگذاری پرونده‌ها ناموفق بود.", + "errorLoadDetail": "بارگذاری جزئیات پرونده ناموفق بود.", + "errorUpdateTask": "به‌روزرسانی وظیفه ناموفق بود." }, "appointments": { "title": "نوبت‌ها", diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index 20ef812..b2ae34e 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -317,7 +317,26 @@ }, "cases": { "title": "Dossiers", - "stubDescription": "Ontvangen labdossiers van gekoppelde klinieken verschijnen hier. Volledige inbox en takenworkflow volgen in een latere fase." + "subtitle": "Labdossiers van gekoppelde klinieken. Wijs taken toe en volg de voortgang per tand.", + "searchPlaceholder": "Zoeken op patiëntnaam of mobiel…", + "emptyList": "Nog geen dossiers ontvangen.", + "selectCaseHint": "Selecteer een dossier uit de lijst om taken te bekijken.", + "fromClinic": "Van {name}", + "sentAt": "Verzonden {date}", + "taskProgressLabel": "Taken: {completed} van {total} voltooid", + "taskProgressShort": "{progress} taken", + "treatmentDetails": "Behandeldetails", + "teethLabel": "Tanden", + "tasksByTooth": "Taken per tand", + "toothGroupTitle": "Tand {tooth} · {type}", + "noTasks": "Er zijn geen taken gegenereerd voor dit dossier.", + "unassigned": "Niet toegewezen", + "statusPending": "In afwachting", + "statusInProgress": "Bezig", + "statusCompleted": "Voltooid", + "errorLoadList": "Dossiers laden mislukt.", + "errorLoadDetail": "Dossierdetails laden mislukt.", + "errorUpdateTask": "Taak bijwerken mislukt." }, "appointments": { "title": "Afspraken", diff --git a/frontend/src/app/[locale]/(dashboard)/cases/page.tsx b/frontend/src/app/[locale]/(dashboard)/cases/page.tsx index 1495096..49657df 100644 --- a/frontend/src/app/[locale]/(dashboard)/cases/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/cases/page.tsx @@ -1,14 +1,315 @@ 'use client'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslations } from 'next-intl'; +import { ToastStack } from '@/components/ui/shared/Toast'; +import { formatApiErrorMessage } from '@/components/shared/formatApiError'; +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'; + +const TREATMENT_TYPE_KEYS = { + consultation: 'typeConsultation', + filling: 'typeFilling', + endo: 'typeEndo', + visit: 'typeVisit', + hygiene: 'typeHygiene', +} as const; + +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)); +} export default function CasesPage() { const t = useTranslations('cases'); + const tTreatment = useTranslations('treatment'); + const tCommon = useTranslations('common'); + const { currentOrganization, user } = useAuth(); + const toast = useToast(); + + const [search, setSearch] = useState(''); + const [cases, setCases] = useState([]); + const [selectedCaseId, setSelectedCaseId] = useState(null); + const [selectedCase, setSelectedCase] = useState(null); + const [members, setMembers] = useState([]); + const [loadingList, setLoadingList] = useState(false); + const [loadingDetail, setLoadingDetail] = useState(false); + const [updatingTaskId, setUpdatingTaskId] = useState(null); + + const canEdit = hasPermission(currentOrganization, 'TAB_CASES_EDIT'); + const locale = user?.language ?? 'en'; + + 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: t('statusPending') }, + { value: 'IN_PROGRESS', label: t('statusInProgress') }, + { value: 'COMPLETED', label: t('statusCompleted') }, + ], + [t], + ); + + const loadCases = async (q: string) => { + setLoadingList(true); + toast.setError(''); + try { + const response = await casesApi.list({ q: q.trim() || undefined, page: 1, limit: 50 }); + setCases(response.data.items); + } catch (error: unknown) { + toast.showError(formatApiErrorMessage(error, t('errorLoadList'))); + } finally { + setLoadingList(false); + } + }; + + const loadDetail = async (caseId: string) => { + setLoadingDetail(true); + toast.setError(''); + try { + const response = await casesApi.getOne(caseId); + setSelectedCase(response.data); + } catch (error: unknown) { + toast.showError(formatApiErrorMessage(error, t('errorLoadDetail'))); + setSelectedCase(null); + } finally { + setLoadingDetail(false); + } + }; + + useEffect(() => { + void loadCases(''); + 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); + return () => clearTimeout(timeout); + // eslint-disable-next-line react-hooks/exhaustive-deps -- debounced search only + }, [search]); + + useEffect(() => { + if (selectedCaseId) { + void loadDetail(selectedCaseId); + } else { + setSelectedCase(null); + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- reload when selection changes + }, [selectedCaseId]); + + async function handleTaskUpdate( + taskId: string, + payload: { assigneeUserId?: string | null; status?: LabTaskStatus }, + ) { + if (!selectedCaseId || !canEdit) return; + + setUpdatingTaskId(taskId); + toast.setError(''); + try { + await casesApi.updateTask(selectedCaseId, taskId, payload); + await loadDetail(selectedCaseId); + await loadCases(search); + } catch (error: unknown) { + toast.showError(formatApiErrorMessage(error, t('errorUpdateTask'))); + } finally { + setUpdatingTaskId(null); + } + } return (
-

{t('title')}

-

{t('stubDescription')}

+
+

{t('title')}

+

{t('subtitle')}

+
+ +
+
+ setSearch(e.target.value)} + 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 ( +
  • + +
  • + ); + })} +
+ )} +
+ +
+ {!selectedCaseId ? ( +

{t('selectCaseHint')}

+ ) : loadingDetail || !selectedCase ? ( +

{tCommon('loading')}

+ ) : ( +
+
+

+ {formatPatientName(selectedCase.patient)} +

+

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

+

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

+

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

+
+ + {selectedCase.details.length > 0 && ( +
+

{t('treatmentDetails')}

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

{t('tasksByTooth')}

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

{t('noTasks')}

+ ) : ( + selectedCase.tasksByTooth.map((group) => ( +
+
+ {t('toothGroupTitle', { + tooth: group.tooth, + type: treatmentLabel(group.treatmentType), + })} +
+
    + {group.tasks.map((task) => ( +
  • + + {task.stepOrder}. {task.stepLabel} + + + +
  • + ))} +
+
+ )) + )} +
+
+ )} +
+
+ +
); } diff --git a/frontend/src/lib/api/cases.ts b/frontend/src/lib/api/cases.ts new file mode 100644 index 0000000..fd6f1da --- /dev/null +++ b/frontend/src/lib/api/cases.ts @@ -0,0 +1,36 @@ +import { apiClient } from './client'; +import type { + AssignableMember, + LabCaseDetail, + LabCaseTask, + ListLabCasesParams, + PaginatedLabCases, +} from '@/types/cases'; + +export const casesApi = { + list: async ( + params: ListLabCasesParams = {}, + ): Promise<{ success: boolean; data: PaginatedLabCases }> => { + const response = await apiClient.get('/cases', { params }); + return response.data; + }, + + getOne: async (id: string): Promise<{ success: boolean; data: LabCaseDetail }> => { + const response = await apiClient.get(`/cases/${id}`); + return response.data; + }, + + listAssignableMembers: async (): Promise<{ success: boolean; data: AssignableMember[] }> => { + const response = await apiClient.get('/cases/assignable-members'); + return response.data; + }, + + updateTask: async ( + caseId: string, + taskId: string, + payload: { assigneeUserId?: string | null; status?: LabCaseTask['status'] }, + ): Promise<{ success: boolean; data: LabCaseTask }> => { + const response = await apiClient.patch(`/cases/${caseId}/tasks/${taskId}`, payload); + return response.data; + }, +}; diff --git a/frontend/src/types/cases.ts b/frontend/src/types/cases.ts new file mode 100644 index 0000000..fed5383 --- /dev/null +++ b/frontend/src/types/cases.ts @@ -0,0 +1,86 @@ +export type LabTaskStatus = 'PENDING' | 'IN_PROGRESS' | 'COMPLETED'; + +export interface LabCaseListItem { + id: string; + sentAt: string | null; + clinic: { id: string; name: string }; + patient: { + id: string; + firstName: string; + lastName: string; + mobile: string; + }; + treatmentTypes: string[]; + taskProgress: { completed: number; total: number }; +} + +export interface LabCaseTask { + id: string; + tooth: string; + treatmentType: string; + stepOrder: number; + stepLabel: string; + status: LabTaskStatus; + assigneeUserId: string | null; + assignee: { id: string; name: string; email: string } | null; +} + +export interface LabCaseTasksByTooth { + tooth: string; + treatmentType: string; + tasks: LabCaseTask[]; +} + +export interface LabCaseDetail { + id: string; + sentAt: string | null; + labComment: string | null; + clinic: { id: string; name: string }; + patient: { + id: string; + firstName: string; + lastName: string; + mobile: string; + }; + appointmentStartAt: string | null; + treatmentTypes: string[]; + details: Array<{ + id: string; + treatmentType: string; + teeth: string[]; + comment: string | null; + }>; + sends: Array<{ + organizationId: string; + organizationName: string; + sentAt: string; + }>; + tasks: LabCaseTask[]; + tasksByTooth: LabCaseTasksByTooth[]; + taskProgress: { completed: number; total: number }; +} + +export interface AssignableMember { + userId: string; + name: string; + email: string; + isOwner: boolean; +} + +export interface ListLabCasesParams { + q?: string; + page?: number; + limit?: number; + clinicOrganizationId?: string; + treatmentType?: string; +} + +export interface PaginatedLabCases { + items: LabCaseListItem[]; + pagination: { + page: number; + limit: number; + total: number; + totalPages: number; + }; +} -- 2.53.0.windows.1 From 7b19d6953c9a620d7a5a4bea0b929e805136e016 Mon Sep 17 00:00:00 2001 From: Admin Date: Sun, 28 Jun 2026 17:14:02 +0330 Subject: [PATCH 05/17] feature: Phase4 - Clinic two-step treatment UI --- .../scripts/clear-clinical-test-data.ts | 50 ++ frontend/messages/en.json | 31 +- frontend/messages/fa.json | 31 +- frontend/messages/nl.json | 31 +- .../src/app/[locale]/(dashboard)/layout.tsx | 4 +- .../src/components/ui/shared/Checkbox.tsx | 42 +- frontend/src/components/ui/shared/Input.tsx | 6 +- .../src/components/ui/shared/SearchBar.tsx | 37 +- .../ui/treatment/LabCasesDispatchPanel.tsx | 320 ++++++++++++ .../ui/treatment/TreatmentCasesEditor.tsx | 299 ----------- .../ui/treatment/TreatmentDetailsEditor.tsx | 196 ++++++++ .../ui/treatment/TreatmentPreviewDialog.tsx | 103 +--- .../ui/treatment/TreatmentWorkspace.tsx | 463 ++++++++++-------- .../ui/treatment/treatmentTypeDisplay.ts | 19 + frontend/src/lib/api/treatment-catalog.ts | 9 + frontend/src/types/treatment-catalog.ts | 6 + frontend/src/types/treatment.ts | 10 + 17 files changed, 1042 insertions(+), 615 deletions(-) create mode 100644 backend/prisma/scripts/clear-clinical-test-data.ts create mode 100644 frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx delete mode 100644 frontend/src/components/ui/treatment/TreatmentCasesEditor.tsx create mode 100644 frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx create mode 100644 frontend/src/components/ui/treatment/treatmentTypeDisplay.ts create mode 100644 frontend/src/lib/api/treatment-catalog.ts create mode 100644 frontend/src/types/treatment-catalog.ts diff --git a/backend/prisma/scripts/clear-clinical-test-data.ts b/backend/prisma/scripts/clear-clinical-test-data.ts new file mode 100644 index 0000000..91bf5ff --- /dev/null +++ b/backend/prisma/scripts/clear-clinical-test-data.ts @@ -0,0 +1,50 @@ +/** + * One-off cleanup: remove appointments, treatments, lab cases, and related rows. + * Keeps patients, organizations, users, and catalog data intact. + * + * Usage: npx ts-node prisma/scripts/clear-clinical-test-data.ts + */ +import { PrismaClient } from '@prisma/client'; +import { config } from 'dotenv'; +import path from 'path'; + +config({ path: path.join(__dirname, '..', '..', '.env') }); + +const prisma = new PrismaClient(); + +async function main() { + const counts = { + labCaseTasks: await prisma.labCaseTask.count(), + labCaseSends: await prisma.labCaseSend.count(), + labCaseDetails: await prisma.labCaseDetail.count(), + labCases: await prisma.labCase.count(), + attachments: await prisma.treatmentDetailAttachment.count(), + treatmentDetails: await prisma.treatmentDetail.count(), + treatments: await prisma.treatment.count(), + appointments: await prisma.appointment.count(), + }; + + console.log('Current row counts:', counts); + + await prisma.$transaction([ + prisma.labCaseTask.deleteMany(), + prisma.labCaseSend.deleteMany(), + prisma.labCaseDetail.deleteMany(), + prisma.labCase.deleteMany(), + prisma.treatmentDetailAttachment.deleteMany(), + prisma.treatmentDetail.deleteMany(), + prisma.treatment.deleteMany(), + prisma.appointment.deleteMany(), + ]); + + console.log('✅ Cleared appointments, treatments, lab cases, tasks, and attachments.'); +} + +main() + .catch((error) => { + console.error('❌ Cleanup failed:', error); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 05c96b0..3f889c3 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -396,6 +396,7 @@ "noPermissionBody": "You do not have permission to view the Treatment tab for this organization.", "title": "Treatment", "subtitleEdit": "Document cases for your appointments, save drafts, and send work to linked organizations.", + "subtitleEditPhase4": "Plan treatment details first, then group lab-dependent work into shipments in the lab dispatch panel.", "subtitleReadOnly": "View-only access — you can review appointments and treatment history but cannot edit.", "pastDayNotice": "Past days are view-only. You can review appointments and history, but treatment cases cannot be added or changed.", "selectedPatient": "Selected patient", @@ -423,6 +424,13 @@ "emptyDay": "No appointments assigned to you on this day.", "casesTitle": "Treatment cases", "casesSubtitle": "Each case has its own teeth, notes, attachments, and destinations for send.", + "detailsTitle": "Treatment details", + "detailsSubtitle": "Plan teeth, type, notes, and attachments for each detail line.", + "addDetail": "Add detail", + "detailLabel": "Detail {n}", + "detailSentBadge": "sent", + "detailLockedInShipment": "This detail was sent to a lab and can no longer be edited.", + "detailsSaveHint": "Lab dispatch is configured separately below.", "addCase": "Add case", "caseLabel": "Case {n}", "comments": "Comments", @@ -441,6 +449,24 @@ "recent": "Recent:", "noOrgMatch": "No active organization matches your search.", "sendThisCase": "Send this case", + "labDispatchTitle": "Lab dispatch", + "labDispatchSubtitle": "Group lab-dependent details into shipments and send them to linked labs.", + "addLabShipment": "Add lab shipment", + "labShipmentLabel": "Shipment {n}", + "includeDetails": "Include treatment details", + "labDetailLine": "Detail {n} · {type} · {teeth}", + "noLabDetails": "No lab-dependent treatment details yet. Add a lab type (e.g. endo) in treatment details above.", + "labDispatchEmpty": "Add a lab shipment to group details and send them to a lab.", + "labComment": "Message for the lab", + "labCommentPlaceholder": "Optional instructions for this shipment…", + "selectLab": "Destination lab", + "selectLabPlaceholder": "Choose a linked lab…", + "sendToLab": "Send to lab", + "saveLabShipments": "Save lab shipments", + "labDispatchSaveHint": "Saves shipment grouping without sending.", + "successLabShipmentsSaved": "Lab shipments saved.", + "errorSaveLabShipments": "Failed to save lab shipments.", + "errorLabCaseNeedsDetails": "Select at least one treatment detail for this shipment.", "saveDraft": "Save treatment draft", "unsavedChanges": "Unsaved changes", "draftSaved": "Draft saved", @@ -464,7 +490,10 @@ "moreCases": "+ {n} more case(s)", "previewDialogTitle": "Treatment preview", "previewDialogSubtitle": "Review cases, attachments, and send destinations.", + "previewDialogSubtitlePhase4": "Review treatment details and attachments.", + "previewLabDispatchHint": "Use the lab dispatch panel in the workspace to send work to labs.", "noCases": "No cases in this treatment.", + "noDetails": "No treatment details in this draft.", "typeLabel": "Type:", "commentsLabel": "Comments:", "commentsEmpty": "Comments: —", @@ -474,7 +503,7 @@ "noActiveOrgs": "No active linked organizations.", "confirmSend": "Confirm send", "toothChartTitle": "FDI tooth chart", - "toothChartHint": "Tap teeth to multi-select. Applies to the active case.", + "toothChartHint": "Tap teeth to multi-select. Applies to the active detail.", "selectedLabel": "Selected:", "selectedEmpty": "—", "upperArch": "Upper arch", diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index 4315638..c8394cb 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -396,6 +396,7 @@ "noPermissionBody": "شما مجوز مشاهده برگه درمان برای این سازمان را ندارید.", "title": "درمان", "subtitleEdit": "پرونده‌های نوبت‌های خود را مستند کنید، پیش‌نویس‌ها را ذخیره کنید و کار را به سازمان‌های مرتبط ارسال کنید.", + "subtitleEditPhase4": "ابتدا جزئیات درمان را برنامه‌ریزی کنید، سپس کار وابسته به لابراتوار را در بخش ارسال لاب گروه‌بندی کنید.", "subtitleReadOnly": "دسترسی فقط خواندنی — می‌توانید نوبت‌ها و تاریخچه درمان را بررسی کنید اما نمی‌توانید ویرایش کنید.", "pastDayNotice": "روزهای گذشته فقط قابل مشاهده هستند. می‌توانید نوبت‌ها و تاریخچه را بررسی کنید، اما پرونده‌های درمانی قابل اضافه یا تغییر نیستند.", "selectedPatient": "بیمار انتخاب شده", @@ -423,6 +424,13 @@ "emptyDay": "هیچ نوبتی به شما در این روز اختصاص داده نشده است.", "casesTitle": "پرونده‌های درمانی", "casesSubtitle": "هر پرونده دارای دندان‌ها، یادداشت‌ها، پیوست‌ها و مقصدهای ارسال خود است.", + "detailsTitle": "جزئیات درمان", + "detailsSubtitle": "دندان‌ها، نوع، یادداشت و پیوست‌ها را برای هر خط جزئیات برنامه‌ریزی کنید.", + "addDetail": "افزودن جزئیات", + "detailLabel": "جزئیات {n}", + "detailSentBadge": "ارسال‌شده", + "detailLockedInShipment": "این جزئیات به لابراتوار ارسال شده و دیگر قابل ویرایش نیست.", + "detailsSaveHint": "ارسال لاب در بخش جداگانه زیر پیکربندی می‌شود.", "addCase": "افزودن پرونده", "caseLabel": "پرونده {n}", "comments": "نظرات", @@ -441,6 +449,24 @@ "recent": "اخیر:", "noOrgMatch": "هیچ سازمان فعالی با جستجوی شما مطابقت ندارد.", "sendThisCase": "ارسال این پرونده", + "labDispatchTitle": "ارسال به لابراتوار", + "labDispatchSubtitle": "جزئیات وابسته به لاب را در محموله‌ها گروه‌بندی کرده و به لابراتوارهای متصل ارسال کنید.", + "addLabShipment": "افزودن محموله لاب", + "labShipmentLabel": "محموله {n}", + "includeDetails": "شامل جزئیات درمان", + "labDetailLine": "جزئیات {n} · {type} · {teeth}", + "noLabDetails": "هنوز جزئیات وابسته به لاب وجود ندارد. نوع لاب (مثلاً اندو) در جزئیات درمان بالا اضافه کنید.", + "labDispatchEmpty": "یک محموله لاب اضافه کنید تا جزئیات را گروه‌بندی و ارسال کنید.", + "labComment": "پیام برای لابراتوار", + "labCommentPlaceholder": "دستورالعمل اختیاری برای این محموله…", + "selectLab": "لابراتوار مقصد", + "selectLabPlaceholder": "یک لابراتوار متصل انتخاب کنید…", + "sendToLab": "ارسال به لابراتوار", + "saveLabShipments": "ذخیره محموله‌های لاب", + "labDispatchSaveHint": "گروه‌بندی محموله را بدون ارسال ذخیره می‌کند.", + "successLabShipmentsSaved": "محموله‌های لاب ذخیره شد.", + "errorSaveLabShipments": "ذخیره محموله‌های لاب ناموفق بود.", + "errorLabCaseNeedsDetails": "حداقل یک جزئیات درمان برای این محموله انتخاب کنید.", "saveDraft": "ذخیره پیش‌نویس درمان", "unsavedChanges": "تغییرات ذخیره‌نشده", "draftSaved": "پیش‌نویس ذخیره شد", @@ -464,6 +490,9 @@ "moreCases": "+ {n} پرونده دیگر", "previewDialogTitle": "پیش‌نمایش درمان", "previewDialogSubtitle": "بررسی پرونده‌ها، پیوست‌ها و مقصدهای ارسال.", + "previewDialogSubtitlePhase4": "بررسی جزئیات درمان و پیوست‌ها.", + "previewLabDispatchHint": "برای ارسال کار به لابراتوار از بخش ارسال لاب در فضای کاری استفاده کنید.", + "noDetails": "جزئیات درمانی در این پیش‌نویس وجود ندارد.", "noCases": "هیچ پرونده‌ای در این درمان وجود ندارد.", "typeLabel": "نوع:", "commentsLabel": "نظرات:", @@ -474,7 +503,7 @@ "noActiveOrgs": "هیچ سازمان مرتبط فعالی وجود ندارد.", "confirmSend": "تأیید ارسال", "toothChartTitle": "نمودار دندان‌ها FDI", - "toothChartHint": "برای انتخاب چندگانه روی دندان‌ها ضربه بزنید. برای پرونده فعال اعمال می‌شود.", + "toothChartHint": "برای انتخاب چندگانه روی دندان‌ها ضربه بزنید. برای جزئیات فعال اعمال می‌شود.", "selectedLabel": "انتخاب شده:", "selectedEmpty": "—", "upperArch": "قوس بالا", diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index b2ae34e..16ea5b7 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -396,6 +396,7 @@ "noPermissionBody": "U heeft geen toestemming om het tabblad Behandeling voor deze organisatie te bekijken.", "title": "Behandeling", "subtitleEdit": "Documenteer casussen voor uw afspraken, sla concepten op en stuur werk naar gekoppelde organisaties.", + "subtitleEditPhase4": "Plan eerst behandeldetails, groepeer daarna lab-afhankelijk werk in het lab-dispatchpaneel.", "subtitleReadOnly": "Alleen-lezen toegang — u kunt afspraken en behandelgeschiedenis bekijken, maar niet bewerken.", "pastDayNotice": "Dagen in het verleden zijn alleen-lezen. U kunt afspraken en geschiedenis bekijken, maar behandelcasussen kunnen niet worden toegevoegd of gewijzigd.", "selectedPatient": "Geselecteerde patiënt", @@ -423,6 +424,13 @@ "emptyDay": "Geen afspraken aan u toegewezen op deze dag.", "casesTitle": "Behandelcasussen", "casesSubtitle": "Elke case heeft zijn eigen tanden, notities, bijlagen en verzendbestemmingen.", + "detailsTitle": "Behandeldetails", + "detailsSubtitle": "Plan tanden, type, notities en bijlagen per detailregel.", + "addDetail": "Detail toevoegen", + "detailLabel": "Detail {n}", + "detailSentBadge": "verzonden", + "detailLockedInShipment": "Dit detail is naar het lab verzonden en kan niet meer worden bewerkt.", + "detailsSaveHint": "Lab-dispatch wordt hieronder apart geconfigureerd.", "addCase": "Case toevoegen", "caseLabel": "Case {n}", "comments": "Opmerkingen", @@ -441,6 +449,24 @@ "recent": "Recent:", "noOrgMatch": "Geen actieve organisatie komt overeen met uw zoekopdracht.", "sendThisCase": "Verzend deze case", + "labDispatchTitle": "Lab-dispatch", + "labDispatchSubtitle": "Groepeer lab-afhankelijke details in zendingen en stuur ze naar gekoppelde labs.", + "addLabShipment": "Labzending toevoegen", + "labShipmentLabel": "Zending {n}", + "includeDetails": "Behandeldetails opnemen", + "labDetailLine": "Detail {n} · {type} · {teeth}", + "noLabDetails": "Nog geen lab-afhankelijke details. Voeg een labtype (bijv. endo) toe in de behandeldetails hierboven.", + "labDispatchEmpty": "Voeg een labzending toe om details te groeperen en naar een lab te sturen.", + "labComment": "Bericht voor het lab", + "labCommentPlaceholder": "Optionele instructies voor deze zending…", + "selectLab": "Bestemmingslab", + "selectLabPlaceholder": "Kies een gekoppeld lab…", + "sendToLab": "Versturen naar lab", + "saveLabShipments": "Labzendingen opslaan", + "labDispatchSaveHint": "Slaat groepering op zonder te verzenden.", + "successLabShipmentsSaved": "Labzendingen opgeslagen.", + "errorSaveLabShipments": "Labzendingen opslaan mislukt.", + "errorLabCaseNeedsDetails": "Selecteer minimaal één behandeldetail voor deze zending.", "saveDraft": "Behandelconcept opslaan", "unsavedChanges": "Niet-opgeslagen wijzigingen", "draftSaved": "Concept opgeslagen", @@ -464,7 +490,10 @@ "moreCases": "+ {n} meer case(s)", "previewDialogTitle": "Behandelvoorbeeld", "previewDialogSubtitle": "Bekijk casussen, bijlagen en verzendbestemmingen.", + "previewDialogSubtitlePhase4": "Bekijk behandeldetails en bijlagen.", + "previewLabDispatchHint": "Gebruik het lab-dispatchpaneel in de werkruimte om werk naar labs te sturen.", "noCases": "Geen casussen in deze behandeling.", + "noDetails": "Geen behandeldetails in dit concept.", "typeLabel": "Type:", "commentsLabel": "Opmerkingen:", "commentsEmpty": "Opmerkingen: —", @@ -474,7 +503,7 @@ "noActiveOrgs": "Geen actieve gekoppelde organisaties.", "confirmSend": "Bevestig verzending", "toothChartTitle": "FDI-tanddiagram", - "toothChartHint": "Tik op tanden om meerdere te selecteren. Geldt voor de actieve case.", + "toothChartHint": "Tik op tanden om meerdere te selecteren. Geldt voor het actieve detail.", "selectedLabel": "Geselecteerd:", "selectedEmpty": "—", "upperArch": "Bovenboog", diff --git a/frontend/src/app/[locale]/(dashboard)/layout.tsx b/frontend/src/app/[locale]/(dashboard)/layout.tsx index 246abcd..037e1c7 100644 --- a/frontend/src/app/[locale]/(dashboard)/layout.tsx +++ b/frontend/src/app/[locale]/(dashboard)/layout.tsx @@ -59,8 +59,8 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
-
-
+
+
{children}
diff --git a/frontend/src/components/ui/shared/Checkbox.tsx b/frontend/src/components/ui/shared/Checkbox.tsx index 2aa1114..453a12e 100644 --- a/frontend/src/components/ui/shared/Checkbox.tsx +++ b/frontend/src/components/ui/shared/Checkbox.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useId } from 'react'; +import { useId, type KeyboardEvent } from 'react'; import { Check } from 'lucide-react'; type CheckboxProps = { @@ -14,6 +14,7 @@ type CheckboxProps = { /** * App design-system checkbox: primary fill when checked, rounded, focus-visible ring. + * Uses a button-like label toggle so mouse clicks do not focus a hidden input (scroll jumps). */ export function Checkbox({ checked, @@ -26,25 +27,42 @@ export function Checkbox({ const genId = useId(); const inputId = id ?? genId; + const toggle = () => { + if (!disabled) onChange(!checked); + }; + + const onKeyDown = (event: KeyboardEvent) => { + if (disabled) return; + if (event.key === ' ' || event.key === 'Enter') { + event.preventDefault(); + toggle(); + } + }; + return (