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')}
- {
- if (!canEditPatients) return;
- toast.clear();
- setPatientForm(EMPTY_PATIENT_FORM);
- setIsCreateOpen(true);
- }}
- title={!canEditPatients ? tCommon('readOnlyAccess') : undefined}
- >
- {t('newPatient')}
-
-
-
-
-
- {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')}
+ {
+ if (!canEditPatients) return;
+ toast.clear();
+ setPatientForm(EMPTY_PATIENT_FORM);
+ setIsCreateOpen(true);
+ }}
+ title={!canEditPatients ? tCommon('readOnlyAccess') : undefined}
+ >
+ {t('newPatient')}
+
+
+
+
+
+ {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 (
+
+ setSelectedCaseId(item.id)}
+ className={`w-full rounded-md border px-3 py-2 text-left transition-colors ${
+ isActive
+ ? 'border-primary bg-primary/5'
+ : 'border-border hover:border-primary/40'
+ }`}
+ >
+
+ {formatPatientName(item.patient)}
+
+ {item.clinic.name}
+
+ {formatDateTime(item.sentAt, locale)}
+ {t('taskProgressShort', { progress })}
+
+
+ {item.treatmentTypes.map(treatmentLabel).join(', ')}
+
+
+
+ );
+ })}
+
+ )}
+
+
+
+ {!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')}
+
+
+ )}
+
+
+
{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}
+
+
+ void handleTaskUpdate(task.id, {
+ status: e.target.value as LabTaskStatus,
+ })
+ }
+ className="rounded border border-border bg-surface px-2 py-1 text-sm disabled:opacity-60"
+ >
+ {statusOptions.map((opt) => (
+
+ {opt.label}
+
+ ))}
+
+
+ void handleTaskUpdate(task.id, {
+ assigneeUserId: e.target.value || null,
+ })
+ }
+ className="rounded border border-border bg-surface px-2 py-1 text-sm disabled:opacity-60"
+ >
+ {t('unassigned')}
+ {members.map((member) => (
+
+ {member.name}
+
+ ))}
+
+
+ ))}
+
+
+ ))
+ )}
+
+
+ )}
+
+
+
+
);
}
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 (
{
+ event.preventDefault();
+ toggle();
+ }}
+ onMouseDown={(event) => {
+ // Avoid focus-driven scroll-into-view in overflow panels.
+ if (event.button === 0) event.preventDefault();
+ }}
className={`
inline-flex items-center gap-2.5 cursor-pointer select-none rounded-[var(--radius-sm)] -m-0.5 p-0.5
- has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary/45 has-[:focus-visible]:ring-offset-2
- has-[:focus-visible]:ring-offset-background-secondary
+ focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/45 focus-visible:ring-offset-2
+ focus-visible:ring-offset-background-secondary
${disabled ? 'opacity-50 cursor-not-allowed' : ''}
${className}
`}
>
- onChange(e.target.checked)}
- />
{
label?: string;
@@ -9,8 +9,8 @@ interface InputProps extends React.InputHTMLAttributes {
export const Input = forwardRef(
({ label, error, icon, className = '', id, ...props }, ref) => {
- const inputId =
- id || `input-${Math.random().toString(36).slice(2, 9)}`;
+ const generatedId = useId();
+ const inputId = id || generatedId;
return (
diff --git a/frontend/src/components/ui/shared/SearchBar.tsx b/frontend/src/components/ui/shared/SearchBar.tsx
index c93b8eb..fb1c9da 100644
--- a/frontend/src/components/ui/shared/SearchBar.tsx
+++ b/frontend/src/components/ui/shared/SearchBar.tsx
@@ -8,6 +8,8 @@ interface SearchBarProps {
placeholder: string;
onSubmit?: () => void;
actions?: ReactNode;
+ /** When true, renders inline without the outer surface-card wrapper (for nested panels). */
+ embedded?: boolean;
}
export function SearchBar({
@@ -16,23 +18,28 @@ export function SearchBar({
placeholder,
onSubmit,
actions,
+ embedded = false,
}: SearchBarProps) {
- return (
-
-
-
- onChange(e.target.value)}
- onKeyDown={(e) => {
- if (e.key === 'Enter') onSubmit?.();
- }}
- icon={ }
- />
-
- {actions &&
{actions}
}
+ const field = (
+
+
+ onChange(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') onSubmit?.();
+ }}
+ icon={ }
+ />
+ {actions &&
{actions}
}
);
+
+ if (embedded) {
+ return field;
+ }
+
+ return
{field}
;
}
diff --git a/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx b/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx
new file mode 100644
index 0000000..068173f
--- /dev/null
+++ b/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx
@@ -0,0 +1,320 @@
+'use client';
+
+import { useTranslations } from 'next-intl';
+import { Button } from '@/components/ui/shared/Button';
+import { Checkbox } from '@/components/ui/shared/Checkbox';
+import { Dropdown } from '@/components/ui/shared/Dropdown';
+import { SearchBar } from '@/components/ui/shared/SearchBar';
+import { formatCaseSentSummary } from '@/components/treatment/caseSendLabel';
+import { CaseSentLabel } from '@/components/ui/treatment/CaseSentLabel';
+import { TREATMENT_TYPE_KEYS, treatmentTypeLabelKey } from '@/components/ui/treatment/treatmentTypeDisplay';
+import type { LabCaseDraft, LinkedOrganizationOption, TreatmentDetailDraft } from '@/types/treatment';
+
+interface LabCasesDispatchPanelProps {
+ details: TreatmentDetailDraft[];
+ labCases: LabCaseDraft[];
+ labDependentCodes: Set
;
+ activeLabCaseId: string | null;
+ onActiveLabCaseChange: (id: string) => void;
+ onLabCasesChange: (labCases: LabCaseDraft[]) => void;
+ disabled: boolean;
+ canEdit: boolean;
+ orgs: LinkedOrganizationOption[];
+ organizationSearch: string;
+ onOrganizationSearchChange: (value: string) => void;
+ recentOrganizationIds: string[];
+ onRecentOrganizationPick: (orgId: string) => void;
+ sendBusyId: string | null;
+ saveLabBusy: boolean;
+ onAddLabCase: () => void;
+ onSaveLabCases: () => void;
+ onSendLabCase: (labCase: LabCaseDraft) => void;
+}
+
+export function LabCasesDispatchPanel({
+ details,
+ labCases,
+ labDependentCodes,
+ activeLabCaseId,
+ onActiveLabCaseChange,
+ onLabCasesChange,
+ disabled,
+ canEdit,
+ orgs,
+ organizationSearch,
+ onOrganizationSearchChange,
+ recentOrganizationIds,
+ onRecentOrganizationPick,
+ sendBusyId,
+ saveLabBusy,
+ onAddLabCase,
+ onSaveLabCases,
+ onSendLabCase,
+}: LabCasesDispatchPanelProps) {
+ const t = useTranslations('treatment');
+ const activeLinkedOrganizations = orgs.filter((o) => o.active);
+ const filteredOrganizations = (() => {
+ const q = organizationSearch.trim().toLowerCase();
+ if (!q) return activeLinkedOrganizations;
+ return activeLinkedOrganizations.filter((o) => o.name.toLowerCase().includes(q));
+ })();
+ const recentOrganizations = recentOrganizationIds
+ .map((id) => activeLinkedOrganizations.find((o) => o.id === id))
+ .filter(Boolean) as LinkedOrganizationOption[];
+
+ const labEligibleDetails = details.filter((d) => labDependentCodes.has(d.treatmentType));
+ const activeLabCase =
+ labCases.find((lc) => lc.clientId === activeLabCaseId) ?? labCases[0] ?? null;
+ const sent = Boolean(activeLabCase?.sentAt);
+
+ function detailSummary(d: TreatmentDetailDraft, idx: number) {
+ const typeKey = treatmentTypeLabelKey(d.treatmentType);
+ const typeLabel =
+ d.treatmentType in TREATMENT_TYPE_KEYS
+ ? t(typeKey as 'typeEndo')
+ : d.treatmentType;
+ const teeth = d.teeth.length ? d.teeth.join(', ') : t('teethNone');
+ return `${t('detailLabel', { n: idx + 1 })} · ${typeLabel} · ${teeth}`;
+ }
+
+ function updateActiveLabCase(patch: Partial) {
+ if (!activeLabCase) return;
+ onLabCasesChange(
+ labCases.map((lc) => (lc.clientId === activeLabCase.clientId ? { ...lc, ...patch } : lc)),
+ );
+ }
+
+ function toggleDetailInActiveLabCase(detailClientId: string, checked: boolean) {
+ if (!activeLabCase || sent) return;
+
+ onLabCasesChange(
+ labCases.map((lc) => {
+ if (lc.sentAt) return lc;
+
+ if (lc.clientId === activeLabCase.clientId) {
+ const set = new Set(lc.detailClientIds);
+ if (checked) set.add(detailClientId);
+ else set.delete(detailClientId);
+ return { ...lc, detailClientIds: [...set] };
+ }
+
+ if (checked) {
+ return {
+ ...lc,
+ detailClientIds: lc.detailClientIds.filter((id) => id !== detailClientId),
+ };
+ }
+ return lc;
+ }),
+ );
+ }
+
+ function detailAssignedElsewhere(detailClientId: string): boolean {
+ if (!activeLabCase) return false;
+ return labCases.some(
+ (lc) =>
+ !lc.sentAt &&
+ lc.clientId !== activeLabCase.clientId &&
+ lc.detailClientIds.includes(detailClientId),
+ );
+ }
+
+ if (labEligibleDetails.length === 0) {
+ return (
+
+
{t('labDispatchTitle')}
+
{t('noLabDetails')}
+
+ );
+ }
+
+ return (
+
+
+
+
{t('labDispatchTitle')}
+
{t('labDispatchSubtitle')}
+
+
+ {t('addLabShipment')}
+
+
+
+ {labCases.length === 0 ? (
+
{t('labDispatchEmpty')}
+ ) : (
+ <>
+
+ {labCases.map((lc, idx) => {
+ const sentSummary = formatCaseSentSummary(
+ lc.sends,
+ {
+ organizationIds: lc.destinationOrganizationId ? [lc.destinationOrganizationId] : [],
+ sentAt: lc.sentAt ?? null,
+ orgs,
+ },
+ t,
+ );
+ return (
+ onActiveLabCaseChange(lc.clientId)}
+ className={`
+ rounded-[var(--radius-md)] border px-3 py-1.5 text-sm transition-colors
+ focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/45
+ ${
+ lc.clientId === activeLabCase?.clientId
+ ? 'border-primary bg-primary-soft font-medium text-text-primary'
+ : 'border-border/70 text-text-secondary hover:border-border hover:bg-background-card/50'
+ }
+ `}
+ >
+ {t('labShipmentLabel', { n: idx + 1 })}
+ {sentSummary ? ` · ${sentSummary}` : ''}
+
+ );
+ })}
+
+
+ {activeLabCase && (
+
+
+
{t('includeDetails')}
+
+ {labEligibleDetails.map((d, idx) => {
+ const assignedElsewhere = detailAssignedElsewhere(d.clientId);
+ const inSentCase = labCases.some(
+ (lc) => lc.sentAt && lc.detailClientIds.includes(d.clientId),
+ );
+ const checked = activeLabCase.detailClientIds.includes(d.clientId);
+ const itemDisabled =
+ disabled || sent || inSentCase || assignedElsewhere;
+
+ return (
+ toggleDetailInActiveLabCase(d.clientId, next)}
+ label={detailSummary(d, idx)}
+ />
+ );
+ })}
+
+
+
+
+ {t('labComment')}
+
+
+
+
{t('selectLab')}
+
+ {recentOrganizations.length > 0 && (
+
+ {t('recent')}
+ {recentOrganizations.map((o) => (
+ onRecentOrganizationPick(o.id)}
+ className="text-xs rounded-[var(--radius-sm)] border border-border/70 px-2 py-1 text-text-secondary hover:text-text-primary hover:border-border focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/35 disabled:opacity-50"
+ >
+ {o.name}
+
+ ))}
+
+ )}
+
+ updateActiveLabCase({
+ destinationOrganizationId: e.target.value || null,
+ })
+ }
+ disabled={disabled || sent || filteredOrganizations.length === 0}
+ >
+ {t('selectLabPlaceholder')}
+ {filteredOrganizations.map((o) => (
+
+ {o.name}
+
+ ))}
+
+ {filteredOrganizations.length === 0 && (
+
{t('noOrgMatch')}
+ )}
+
+
+
+ onSendLabCase(activeLabCase)}
+ >
+ {t('sendToLab')}
+
+ {sent && (
+
+ )}
+
+
+ )}
+
+ {canEdit && (
+
+
+ {t('saveLabShipments')}
+
+
{t('labDispatchSaveHint')}
+
+ )}
+ >
+ )}
+
+ );
+}
diff --git a/frontend/src/components/ui/treatment/TreatmentCasesEditor.tsx b/frontend/src/components/ui/treatment/TreatmentCasesEditor.tsx
deleted file mode 100644
index 0d24eae..0000000
--- a/frontend/src/components/ui/treatment/TreatmentCasesEditor.tsx
+++ /dev/null
@@ -1,299 +0,0 @@
-'use client';
-
-import { useRef } from 'react';
-import { useTranslations } from 'next-intl';
-import { Button } from '@/components/ui/shared/Button';
-import { Checkbox } from '@/components/ui/shared/Checkbox';
-import { Dropdown } from '@/components/ui/shared/Dropdown';
-import { SearchBar } from '@/components/ui/shared/SearchBar';
-import type { LinkedOrganizationOption, TreatmentCaseDraft } from '@/types/treatment';
-import { formatCaseSentSummary } from '@/components/treatment/caseSendLabel';
-import { CaseSentLabel } from '@/components/ui/treatment/CaseSentLabel';
-
-interface TreatmentCasesEditorProps {
- cases: TreatmentCaseDraft[];
- activeCaseId: string;
- onActiveCaseChange: (id: string) => void;
- onCasesChange: (cases: TreatmentCaseDraft[]) => void;
- disabled: boolean;
- canEdit: boolean;
- isDirty: boolean;
- saveBusy: boolean;
- sendBusyId: string | null;
- uploadBusy: boolean;
- orgs: LinkedOrganizationOption[];
- organizationSearch: string;
- onOrganizationSearchChange: (value: string) => void;
- recentOrganizationIds: string[];
- onRecentOrganizationPick: (orgId: string) => void;
- onAddCase: () => void;
- onPreview: () => void;
- onSave: () => void;
- onSendCase: (c: TreatmentCaseDraft) => void;
- onUploadFiles: (files: FileList | null) => void;
-}
-
-export function TreatmentCasesEditor({
- cases,
- activeCaseId,
- onActiveCaseChange,
- onCasesChange,
- disabled,
- canEdit,
- isDirty,
- saveBusy,
- sendBusyId,
- uploadBusy,
- orgs,
- organizationSearch,
- onOrganizationSearchChange,
- recentOrganizationIds,
- onRecentOrganizationPick,
- onAddCase,
- onPreview,
- onSave,
- onSendCase,
- onUploadFiles,
-}: TreatmentCasesEditorProps) {
- const t = useTranslations('treatment');
- const tCommon = useTranslations('common');
- const attachmentInputRef = useRef(null);
- const activeCase = cases.find((c) => c.clientId === activeCaseId) ?? cases[0];
- const activeLinkedOrganizations = orgs.filter((o) => o.active);
- const filteredOrganizations = (() => {
- const q = organizationSearch.trim().toLowerCase();
- if (!q) return activeLinkedOrganizations;
- return activeLinkedOrganizations.filter((o) => o.name.toLowerCase().includes(q));
- })();
- const recentOrganizations = recentOrganizationIds
- .map((id) => activeLinkedOrganizations.find((o) => o.id === id))
- .filter(Boolean) as LinkedOrganizationOption[];
-
- const treatmentTypeTextColor = activeCase
- ? (
- {
- consultation: '#ddd6fe',
- filling: '#fed7aa',
- endo: '#fecaca',
- visit: '#bae6fd',
- hygiene: '#d9f99d',
- } as Record
- )[activeCase.treatmentType]
- : undefined;
-
- if (!activeCase) return null;
-
- return (
-
-
-
-
{t('casesTitle')}
-
- {t('casesSubtitle')}
-
-
-
-
- {tCommon('preview')}
-
-
- {t('addCase')}
-
-
-
-
-
- {cases.map((c, idx) => {
- const sentSummary = formatCaseSentSummary(c.sends, {
- organizationIds: c.sendToOrganizationIds ?? [],
- sentAt: c.sentAt ?? null,
- orgs,
- }, t);
- return (
- onActiveCaseChange(c.clientId)}
- className={`
- rounded-[var(--radius-md)] border px-3 py-1.5 text-sm transition-colors
- focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/45
- ${
- c.clientId === activeCaseId
- ? 'border-primary bg-primary-soft font-medium text-text-primary'
- : 'border-border/70 text-text-secondary hover:border-border hover:bg-background-card/50'
- }
- `}
- >
- {t('caseLabel', { n: idx + 1 })}
- {sentSummary ? ` · ${sentSummary}` : ''}
-
- );
- })}
-
-
-
-
- {t('comments')}
-
-
-
- {
- const nextType = e.target.value as TreatmentCaseDraft['treatmentType'];
- onCasesChange(
- cases.map((c) =>
- c.clientId === activeCaseId ? { ...c, treatmentType: nextType } : c,
- ),
- );
- }}
- disabled={disabled || Boolean(activeCase.sentAt)}
- className="capitalize"
- style={{ color: treatmentTypeTextColor }}
- >
- {t('typeConsultation')}
- {t('typeFilling')}
- {t('typeEndo')}
- {t('typeVisit')}
- {t('typeHygiene')}
-
-
-
-
-
{t('attachments')}
-
{
- onUploadFiles(e.target.files);
- e.target.value = '';
- }}
- className="sr-only"
- aria-label={t('attachFiles')}
- />
-
attachmentInputRef.current?.click()}
- aria-controls="treatment-case-attachments"
- >
- {t('chooseFiles')}
-
- {activeCase.attachmentMetas.length > 0 && (
-
- {activeCase.attachmentMetas.map((f) => (
-
- {f.fileName} ({(f.sizeBytes / 1024).toFixed(1)} KB)
-
- ))}
-
- )}
-
-
-
-
- {t('sendToOrgs')}
-
-
-
- {recentOrganizations.length > 0 && (
-
- {t('recent')}
- {recentOrganizations.map((o) => (
- onRecentOrganizationPick(o.id)}
- className="text-xs rounded-[var(--radius-sm)] border border-border/70 px-2 py-1 text-text-secondary hover:text-text-primary hover:border-border focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/35 disabled:opacity-50"
- >
- {o.name}
-
- ))}
-
- )}
-
-
- {filteredOrganizations.map((o) => (
-
{
- onCasesChange(
- cases.map((c) => {
- if (c.clientId !== activeCaseId) return c;
- const next = new Set(c.sendToOrganizationIds);
- if (checked) next.add(o.id);
- else next.delete(o.id);
- return { ...c, sendToOrganizationIds: [...next] };
- }),
- );
- }}
- label={o.name}
- />
- ))}
- {filteredOrganizations.length === 0 && (
- {t('noOrgMatch')}
- )}
-
-
-
-
- onSendCase(activeCase)}
- >
- {t('sendThisCase')}
-
- {activeCase.sentAt && (
-
- )}
-
-
-
- {canEdit && (
-
-
- {t('saveDraft')}
-
-
- {isDirty ? t('unsavedChanges') : t('draftSaved')}. {t('sendSavesFirst')}
-
-
- )}
-
- );
-}
diff --git a/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx b/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx
new file mode 100644
index 0000000..768586d
--- /dev/null
+++ b/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx
@@ -0,0 +1,196 @@
+'use client';
+
+import { useRef } from 'react';
+import { useTranslations } from 'next-intl';
+import { Button } from '@/components/ui/shared/Button';
+import { Dropdown } from '@/components/ui/shared/Dropdown';
+import type { TreatmentDetailDraft } from '@/types/treatment';
+import { TREATMENT_TYPE_COLORS, treatmentTypeLabelKey } from '@/components/ui/treatment/treatmentTypeDisplay';
+
+interface TreatmentDetailsEditorProps {
+ details: TreatmentDetailDraft[];
+ activeDetailId: string;
+ onActiveDetailChange: (id: string) => void;
+ onDetailsChange: (details: TreatmentDetailDraft[]) => void;
+ isDetailLocked: (detail: TreatmentDetailDraft) => boolean;
+ disabled: boolean;
+ canEdit: boolean;
+ isDirty: boolean;
+ saveBusy: boolean;
+ uploadBusy: boolean;
+ onAddDetail: () => void;
+ onPreview: () => void;
+ onSave: () => void;
+ onUploadFiles: (files: FileList | null) => void;
+}
+
+export function TreatmentDetailsEditor({
+ details,
+ activeDetailId,
+ onActiveDetailChange,
+ onDetailsChange,
+ isDetailLocked,
+ disabled,
+ canEdit,
+ isDirty,
+ saveBusy,
+ uploadBusy,
+ onAddDetail,
+ onPreview,
+ onSave,
+ onUploadFiles,
+}: TreatmentDetailsEditorProps) {
+ const t = useTranslations('treatment');
+ const tCommon = useTranslations('common');
+ const attachmentInputRef = useRef(null);
+ const activeDetail = details.find((d) => d.clientId === activeDetailId) ?? details[0];
+
+ if (!activeDetail) return null;
+
+ const locked = isDetailLocked(activeDetail);
+ const readOnly = disabled || locked;
+ const treatmentTypeTextColor = TREATMENT_TYPE_COLORS[activeDetail.treatmentType];
+
+ return (
+
+
+
+
{t('detailsTitle')}
+
{t('detailsSubtitle')}
+
+
+
+ {tCommon('preview')}
+
+
+ {t('addDetail')}
+
+
+
+
+
+ {details.map((d, idx) => (
+ onActiveDetailChange(d.clientId)}
+ className={`
+ rounded-[var(--radius-md)] border px-3 py-1.5 text-sm transition-colors
+ focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/45
+ ${
+ d.clientId === activeDetailId
+ ? 'border-primary bg-primary-soft font-medium text-text-primary'
+ : 'border-border/70 text-text-secondary hover:border-border hover:bg-background-card/50'
+ }
+ `}
+ >
+ {t('detailLabel', { n: idx + 1 })}
+ {isDetailLocked(d) ? ` · ${t('detailSentBadge')}` : ''}
+
+ ))}
+
+
+
+ {locked && (
+
+ {t('detailLockedInShipment')}
+
+ )}
+
+
+ {t('comments')}
+
+
+
+ {
+ const nextType = e.target.value as TreatmentDetailDraft['treatmentType'];
+ onDetailsChange(
+ details.map((d) =>
+ d.clientId === activeDetailId ? { ...d, treatmentType: nextType } : d,
+ ),
+ );
+ }}
+ disabled={readOnly}
+ className="capitalize"
+ style={{ color: treatmentTypeTextColor }}
+ >
+ {t('typeConsultation')}
+ {t('typeFilling')}
+ {t('typeEndo')}
+ {t('typeVisit')}
+ {t('typeHygiene')}
+
+
+
+
+
{t('attachments')}
+
{
+ onUploadFiles(e.target.files);
+ e.target.value = '';
+ }}
+ className="sr-only"
+ aria-label={t('attachFiles')}
+ />
+
attachmentInputRef.current?.click()}
+ aria-controls="treatment-detail-attachments"
+ >
+ {t('chooseFiles')}
+
+ {activeDetail.attachmentMetas.length > 0 && (
+
+ {activeDetail.attachmentMetas.map((f) => (
+
+ {f.fileName} ({(f.sizeBytes / 1024).toFixed(1)} KB)
+
+ ))}
+
+ )}
+
+
+
+ {canEdit && (
+
+
+ {t('saveDraft')}
+
+
+ {isDirty ? t('unsavedChanges') : t('draftSaved')}. {t('detailsSaveHint')}
+
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/ui/treatment/TreatmentPreviewDialog.tsx b/frontend/src/components/ui/treatment/TreatmentPreviewDialog.tsx
index 568a590..ccb9198 100644
--- a/frontend/src/components/ui/treatment/TreatmentPreviewDialog.tsx
+++ b/frontend/src/components/ui/treatment/TreatmentPreviewDialog.tsx
@@ -1,14 +1,13 @@
'use client';
-import { useRef, useState } from 'react';
+import { useRef } from 'react';
import { useTranslations } from 'next-intl';
-import { Loader2, Paperclip, Send } from 'lucide-react';
+import { Loader2, Paperclip } from 'lucide-react';
import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
-import { Button } from '@/components/ui/shared/Button';
-import { Checkbox } from '@/components/ui/shared/Checkbox';
import type { LinkedOrganizationOption, PastTreatment, PastTreatmentCase } from '@/types/treatment';
import { CaseSentLabel } from '@/components/ui/treatment/CaseSentLabel';
import { TreatmentLatestAttachmentPreview } from '@/components/ui/treatment/TreatmentLatestAttachmentPreview';
+import { treatmentTypeLabelKey } from '@/components/ui/treatment/treatmentTypeDisplay';
export type TreatmentPreviewMode = 'readonly' | 'editable';
@@ -18,12 +17,8 @@ interface TreatmentPreviewDialogProps {
treatment: PastTreatment | null;
mode: TreatmentPreviewMode;
orgs?: LinkedOrganizationOption[];
- sendBusyCaseId?: string | null;
uploadBusyCaseId?: string | null;
onAttach?: (caseKey: string, files: FileList) => void | Promise;
- onSend?: (caseKey: string, organizationIds: string[]) => void | Promise;
- getCaseOrgIds?: (caseKey: string) => string[];
- onToggleCaseOrg?: (caseKey: string, organizationId: string, checked: boolean) => void;
}
function caseKey(c: PastTreatmentCase): string {
@@ -33,35 +28,21 @@ function caseKey(c: PastTreatmentCase): string {
const caseActionIconClass =
'inline-flex items-center justify-center rounded-[var(--radius-sm)] p-1.5 text-text-secondary transition-colors hover:bg-background-card/80 hover:text-text-primary disabled:cursor-not-allowed disabled:opacity-40';
-const TREATMENT_TYPE_KEYS = {
- consultation: 'typeConsultation',
- filling: 'typeFilling',
- endo: 'typeEndo',
- visit: 'typeVisit',
- hygiene: 'typeHygiene',
-} as const;
-
export function TreatmentPreviewDialog({
open,
onClose,
treatment,
mode,
orgs = [],
- sendBusyCaseId,
uploadBusyCaseId,
onAttach,
- onSend,
- getCaseOrgIds,
- onToggleCaseOrg,
}: TreatmentPreviewDialogProps) {
const t = useTranslations('treatment');
- const [expandedSendCaseId, setExpandedSendCaseId] = useState(null);
const fileInputsRef = useRef>({});
if (!open || !treatment) return null;
const editable = mode === 'editable';
- const activeOrgs = orgs.filter((o) => o.active);
return (
@@ -76,9 +57,7 @@ export function TreatmentPreviewDialog({
{t('previewDialogTitle')}
-
- {t('previewDialogSubtitle')}
-
+
{t('previewDialogSubtitlePhase4')}
@@ -94,8 +73,12 @@ export function TreatmentPreviewDialog({
{t('statusLabel')} {treatment.status}
+ {editable && (
+
{t('previewLabDispatchHint')}
+ )}
+
{treatment.details.length === 0 ? (
-
{t('noCases')}
+
{t('noDetails')}
) : (
{treatment.details.map((c, idx) => {
@@ -105,15 +88,10 @@ export function TreatmentPreviewDialog({
attachments.length > 0 ? attachments[attachments.length - 1] : null;
const sent = Boolean(c.sentAt);
const actionsEnabled = editable && !sent;
- const selectedOrgIds =
- getCaseOrgIds?.(key) ??
- (c.destinationOrganizationId ? [c.destinationOrganizationId] : []);
- const sendExpanded = expandedSendCaseId === key;
const comment = c.notes?.trim() ?? '';
const attachBusy = uploadBusyCaseId === key;
- const sendBusy = sendBusyCaseId === key;
- const typeKey = TREATMENT_TYPE_KEYS[c.treatmentType as keyof typeof TREATMENT_TYPE_KEYS];
- const typeLabel = typeKey ? t(typeKey) : c.treatmentType;
+ const typeKey = treatmentTypeLabelKey(c.treatmentType);
+ const typeLabel = t(typeKey as 'typeConsultation');
return (
- {t('caseLabel', { n: idx + 1 })}
+ {t('detailLabel', { n: idx + 1 })}
- {actionsEnabled && (
+ {actionsEnabled && onAttach && (
)}
@@ -191,7 +150,7 @@ export function TreatmentPreviewDialog({
{t('commentsLabel')} {comment}
) : (
-
{t('commentsEmpty')}
+
{t('commentsEmpty')}
)}
@@ -209,38 +168,6 @@ export function TreatmentPreviewDialog({
-
- {sendExpanded && editable && !sent && (
-
-
- {t('sendToLinkedOrgs')}
-
- {activeOrgs.length === 0 ? (
-
{t('noActiveOrgs')}
- ) : (
-
- {activeOrgs.map((o) => (
- onToggleCaseOrg?.(key, o.id, checked)}
- label={o.name}
- />
- ))}
-
- )}
-
void onSend?.(key, selectedOrgIds)}
- >
- {t('confirmSend')}
-
-
- )}
);
})}
diff --git a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx
index 265a427..083dbff 100644
--- a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx
+++ b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx
@@ -4,14 +4,16 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslations } from 'next-intl';
import { AppointmentsStrip } from '@/components/ui/treatment/AppointmentsStrip';
import { FdiToothChart } from '@/components/ui/treatment/FdiToothChart';
+import { LabCasesDispatchPanel } from '@/components/ui/treatment/LabCasesDispatchPanel';
import { PastTreatmentsPanel } from '@/components/ui/treatment/PastTreatmentsPanel';
-import { TreatmentCasesEditor } from '@/components/ui/treatment/TreatmentCasesEditor';
+import { TreatmentDetailsEditor } from '@/components/ui/treatment/TreatmentDetailsEditor';
import { TreatmentPreviewCard } from '@/components/ui/treatment/TreatmentPreviewCard';
import {
TreatmentPreviewDialog,
type TreatmentPreviewMode,
} from '@/components/ui/treatment/TreatmentPreviewDialog';
import { ToastStack } from '@/components/ui/shared/Toast';
+import { treatmentTypeLabelKey } from '@/components/ui/treatment/treatmentTypeDisplay';
import {
addCalendarDays,
compareLocalDayStart,
@@ -19,6 +21,7 @@ import {
startOfLocalDay,
} from '@/components/appointments/appointmentTime';
import { appointmentsApi } from '@/lib/api/appointments';
+import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
import { treatmentsApi } from '@/lib/api/treatments';
import { pickAutoAppointment } from '@/components/shared/treatmentSelection';
import { canEditTreatment, canViewTreatment } from '@/components/shared/permissions';
@@ -28,27 +31,21 @@ import type { Organization } from '@/types/organization';
import type { AppointmentRecord } from '@/types/appointment';
import type {
FdiToothId,
+ LabCaseDraft,
LinkedOrganizationOption,
+ PastLabCase,
PastTreatment,
PastTreatmentCase,
TreatmentAppointment,
- TreatmentCaseDraft,
+ TreatmentDetailDraft,
} from '@/types/treatment';
-const TREATMENT_TYPE_KEYS = {
- consultation: 'typeConsultation',
- filling: 'typeFilling',
- endo: 'typeEndo',
- visit: 'typeVisit',
- hygiene: 'typeHygiene',
-} as const;
-
-function newCase(): TreatmentCaseDraft {
+function newDetail(): TreatmentDetailDraft {
return {
clientId:
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
- : `case-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
+ : `detail-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
treatmentType: 'consultation',
teeth: [],
comment: '',
@@ -58,6 +55,20 @@ function newCase(): TreatmentCaseDraft {
};
}
+function newLabCaseDraft(): LabCaseDraft {
+ return {
+ clientId:
+ typeof crypto !== 'undefined' && 'randomUUID' in crypto
+ ? crypto.randomUUID()
+ : `lab-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
+ destinationOrganizationId: null,
+ labComment: '',
+ detailClientIds: [],
+ sentAt: null,
+ sends: [],
+ };
+}
+
function mapAppointment(record: AppointmentRecord): TreatmentAppointment {
return {
id: record.id,
@@ -71,7 +82,7 @@ function mapAppointment(record: AppointmentRecord): TreatmentAppointment {
};
}
-function mapDetailFromApi(d: PastTreatmentCase): TreatmentCaseDraft {
+function mapDetailFromApi(d: PastTreatmentCase): TreatmentDetailDraft {
return {
clientId: d.clientId,
id: d.id,
@@ -86,23 +97,33 @@ function mapDetailFromApi(d: PastTreatmentCase): TreatmentCaseDraft {
};
}
-function serializeCases(cases: TreatmentCaseDraft[]) {
+function mapLabCaseDraftFromApi(lc: PastLabCase): LabCaseDraft {
+ return {
+ clientId: lc.clientId,
+ id: lc.id,
+ destinationOrganizationId: lc.destinationOrganizationId,
+ labComment: lc.labComment ?? '',
+ detailClientIds: lc.details.map((d) => d.clientId),
+ sentAt: lc.sentAt ?? null,
+ sends: lc.sends ?? [],
+ };
+}
+
+function serializeDetails(details: TreatmentDetailDraft[]) {
return JSON.stringify(
- cases.map((c) => ({
- clientId: c.clientId,
- id: c.id,
- treatmentType: c.treatmentType,
- teeth: c.teeth,
- comment: c.comment,
- attachmentMetas: c.attachmentMetas,
- sendToOrganizationIds: c.sendToOrganizationIds,
- sentAt: c.sentAt,
+ details.map((d) => ({
+ clientId: d.clientId,
+ id: d.id,
+ treatmentType: d.treatmentType,
+ teeth: d.teeth,
+ comment: d.comment,
+ attachmentMetas: d.attachmentMetas,
})),
);
}
-function casesToPreviewTreatment(
- cases: TreatmentCaseDraft[],
+function detailsToPreviewTreatment(
+ details: TreatmentDetailDraft[],
meta: { title: string; patientId: string; treatmentAt: string; status: string; id?: string },
): PastTreatment {
return {
@@ -111,17 +132,17 @@ function casesToPreviewTreatment(
title: meta.title,
treatmentAt: meta.treatmentAt,
status: meta.status,
- 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,
- labCaseId: c.labCaseId ?? null,
- destinationOrganizationId: c.sendToOrganizationIds[0] ?? null,
- sends: c.sends ?? [],
- sentAt: c.sentAt ?? null,
+ details: details.map((d, idx) => ({
+ id: d.id ?? d.clientId ?? `draft-${idx + 1}`,
+ clientId: d.clientId,
+ treatmentType: d.treatmentType,
+ teeth: d.teeth,
+ notes: d.comment || null,
+ attachmentMetas: d.attachmentMetas,
+ labCaseId: d.labCaseId ?? null,
+ destinationOrganizationId: d.sendToOrganizationIds[0] ?? null,
+ sends: d.sends ?? [],
+ sentAt: d.sentAt ?? null,
})),
labCases: [],
documents: [],
@@ -152,17 +173,21 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
const [historyLoading, setHistoryLoading] = useState(false);
const [orgs, setOrgs] = useState([]);
+ const [labDependentCodes, setLabDependentCodes] = useState>(new Set());
- const [cases, setCases] = useState(() => [newCase()]);
- const [activeCaseId, setActiveCaseId] = useState(() => cases[0].clientId);
+ const [details, setDetails] = useState(() => [newDetail()]);
+ const [labCaseDrafts, setLabCaseDrafts] = useState([]);
+ const [activeDetailId, setActiveDetailId] = useState(() => details[0].clientId);
+ const [activeLabCaseId, setActiveLabCaseId] = useState(null);
const [savedSnapshot, setSavedSnapshot] = useState(null);
const selectionLockedRef = useRef(selectionLocked);
selectionLockedRef.current = selectionLocked;
const [saveBusy, setSaveBusy] = useState(false);
+ const [saveLabBusy, setSaveLabBusy] = useState(false);
const [sendBusyId, setSendBusyId] = useState(null);
- const [uploadBusyCaseId, setUploadBusyCaseId] = useState(null);
+ const [uploadBusyDetailId, setUploadBusyDetailId] = useState(null);
const [organizationSearch, setOrganizationSearch] = useState('');
const [recentOrganizationIds, setRecentOrganizationIds] = useState([]);
@@ -170,12 +195,18 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
const [previewTreatment, setPreviewTreatment] = useState(null);
const [previewMode, setPreviewMode] = useState('readonly');
+ const isDetailLocked = useCallback(
+ (detail: TreatmentDetailDraft) =>
+ labCaseDrafts.some((lc) => lc.sentAt && lc.detailClientIds.includes(detail.clientId)),
+ [labCaseDrafts],
+ );
+
const isDirty = useMemo(() => {
if (savedSnapshot === null) {
- return cases.length !== 1 || cases[0].comment !== '' || cases[0].teeth.length > 0;
+ return details.length !== 1 || details[0].comment !== '' || details[0].teeth.length > 0;
}
- return serializeCases(cases) !== savedSnapshot;
- }, [cases, savedSnapshot]);
+ return serializeDetails(details) !== savedSnapshot;
+ }, [details, savedSnapshot]);
const selectedAppointment = useMemo(
() => appointments.find((a) => a.id === selectedAppointmentId) ?? null,
@@ -189,16 +220,16 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
const canEditTreatmentForDay = canEdit && Boolean(selectedAppointment) && !isViewingPastDay;
- const activeCase = useMemo(
- () => cases.find((c) => c.clientId === activeCaseId) ?? cases[0],
- [cases, activeCaseId],
+ const activeDetail = useMemo(
+ () => details.find((d) => d.clientId === activeDetailId) ?? details[0],
+ [details, activeDetailId],
);
- const selectedTeethSet = useMemo(() => new Set(activeCase?.teeth ?? []), [activeCase?.teeth]);
+ const selectedTeethSet = useMemo(() => new Set(activeDetail?.teeth ?? []), [activeDetail?.teeth]);
const currentDraftPreview = useMemo(() => {
if (!selectedAppointment) return null;
- return casesToPreviewTreatment(cases, {
+ return detailsToPreviewTreatment(details, {
title: t('draftTitle', {
patientName: `${selectedAppointment.patientFirstName} ${selectedAppointment.patientLastName}`,
}),
@@ -206,7 +237,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
treatmentAt: new Date().toISOString(),
status: 'draft',
});
- }, [cases, selectedAppointment, t]);
+ }, [details, selectedAppointment, t]);
useEffect(() => {
setSelectionLocked(false);
@@ -262,8 +293,15 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
let cancelled = false;
void (async () => {
try {
- const list = await treatmentsApi.listLinkedOrganizations();
- if (!cancelled) setOrgs(list.data);
+ const [orgsResponse, catalogResponse] = await Promise.all([
+ treatmentsApi.listLinkedOrganizations(),
+ treatmentCatalogApi.list(),
+ ]);
+ if (cancelled) return;
+ setOrgs(orgsResponse.data);
+ setLabDependentCodes(
+ new Set(catalogResponse.data.filter((entry) => entry.labDependent).map((entry) => entry.code)),
+ );
} catch (error: unknown) {
if (!cancelled) {
showError(formatApiErrorMessage(error, t('errorLoadOrgs')));
@@ -311,18 +349,22 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
if (response.data?.details?.length) {
const mapped = response.data.details.map(mapDetailFromApi);
- setCases(mapped);
- setActiveCaseId((prev) => {
- const stillExists = mapped.some((c) => c.clientId === prev);
+ setDetails(mapped);
+ setActiveDetailId((prev) => {
+ const stillExists = mapped.some((d) => d.clientId === prev);
return stillExists ? prev : mapped[0].clientId;
});
- setSavedSnapshot(serializeCases(mapped));
+ setSavedSnapshot(serializeDetails(mapped));
} else {
- const first = newCase();
- setCases([first]);
- setActiveCaseId(first.clientId);
- setSavedSnapshot(serializeCases([first]));
+ const first = newDetail();
+ setDetails([first]);
+ setActiveDetailId(first.clientId);
+ setSavedSnapshot(serializeDetails([first]));
}
+
+ const mappedLabCases = (response.data?.labCases ?? []).map(mapLabCaseDraftFromApi);
+ setLabCaseDrafts(mappedLabCases);
+ setActiveLabCaseId(mappedLabCases[0]?.clientId ?? null);
setOrganizationSearch('');
} catch (error: unknown) {
if (!cancelled) {
@@ -357,31 +399,31 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
[confirmDiscardIfDirty],
);
- const uploadForCase = useCallback(
- async (caseClientId: string, files: FileList | File[]) => {
+ const uploadForDetail = useCallback(
+ async (detailClientId: string, files: FileList | File[]) => {
if (!canEditTreatmentForDay || !selectedAppointment) return;
const list = files instanceof FileList ? Array.from(files) : files;
if (!list.length) return;
- setUploadBusyCaseId(caseClientId);
+ setUploadBusyDetailId(detailClientId);
try {
const uploaded = await treatmentsApi.uploadCaseAttachments(
selectedAppointment.id,
- caseClientId,
+ detailClientId,
list,
);
- setCases((prev) =>
- prev.map((c) =>
- c.clientId === caseClientId
- ? { ...c, attachmentMetas: [...c.attachmentMetas, ...uploaded.data] }
- : c,
+ setDetails((prev) =>
+ prev.map((d) =>
+ d.clientId === detailClientId
+ ? { ...d, attachmentMetas: [...d.attachmentMetas, ...uploaded.data] }
+ : d,
),
);
showSuccess(t('successFilesUploaded', { count: uploaded.data.length }));
} catch (error: unknown) {
showError(formatApiErrorMessage(error, t('errorUpload')));
} finally {
- setUploadBusyCaseId(null);
+ setUploadBusyDetailId(null);
}
},
[canEditTreatmentForDay, selectedAppointment, showSuccess, showError, t],
@@ -391,7 +433,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
if (!selectedAppointment) throw new Error('No appointment selected');
const response = await treatmentsApi.saveDraft(selectedAppointment.id, {
- details: cases.map(({ clientId, id, treatmentType, teeth, comment, attachmentMetas }) => ({
+ details: details.map(({ clientId, id, treatmentType, teeth, comment, attachmentMetas }) => ({
clientId,
id,
treatmentType,
@@ -401,14 +443,50 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
})),
});
const mapped = response.data.details.map(mapDetailFromApi);
- setCases(mapped);
- setActiveCaseId((prev) => {
- const stillExists = mapped.some((c) => c.clientId === prev);
+ setDetails(mapped);
+ setActiveDetailId((prev) => {
+ const stillExists = mapped.some((d) => d.clientId === prev);
return stillExists ? prev : mapped[0]?.clientId ?? prev;
});
- setSavedSnapshot(serializeCases(mapped));
+ setSavedSnapshot(serializeDetails(mapped));
return response.data;
- }, [cases, selectedAppointment]);
+ }, [details, selectedAppointment]);
+
+ const persistLabCases = useCallback(
+ async (savedTreatment: PastTreatment) => {
+ if (!selectedAppointment) throw new Error('No appointment selected');
+
+ const detailIdByClientId = new Map(
+ savedTreatment.details.map((d) => [d.clientId, d.id]),
+ );
+
+ const payload = labCaseDrafts.map((lc) => ({
+ clientId: lc.clientId,
+ id: lc.id,
+ destinationOrganizationId: lc.destinationOrganizationId ?? undefined,
+ labComment: lc.labComment.trim() || undefined,
+ treatmentDetailIds: lc.detailClientIds
+ .map((clientId) => detailIdByClientId.get(clientId))
+ .filter((id): id is string => Boolean(id)),
+ }));
+
+ if (payload.length === 0) {
+ return savedTreatment;
+ }
+
+ const response = await treatmentsApi.saveLabCases(selectedAppointment.id, {
+ labCases: payload,
+ });
+ const mapped = response.data.labCases.map(mapLabCaseDraftFromApi);
+ setLabCaseDrafts(mapped);
+ setActiveLabCaseId((prev) => {
+ if (prev && mapped.some((lc) => lc.clientId === prev)) return prev;
+ return mapped[0]?.clientId ?? null;
+ });
+ return response.data;
+ },
+ [labCaseDrafts, selectedAppointment],
+ );
const handleSaveAll = useCallback(async () => {
if (!canEditTreatmentForDay || !selectedAppointment) return;
@@ -423,70 +501,69 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
}
}, [canEditTreatmentForDay, selectedAppointment, persistDraft, showSuccess, showError, t]);
- const handleSendCase = useCallback(
- async (treatmentCase: TreatmentCaseDraft) => {
+ const handleSaveLabCases = useCallback(async () => {
+ if (!canEditTreatmentForDay || !selectedAppointment) return;
+ setSaveLabBusy(true);
+ try {
+ const saved = await persistDraft();
+ await persistLabCases(saved);
+ showSuccess(t('successLabShipmentsSaved'));
+ } catch (error: unknown) {
+ showError(formatApiErrorMessage(error, t('errorSaveLabShipments')));
+ } finally {
+ setSaveLabBusy(false);
+ }
+ }, [
+ canEditTreatmentForDay,
+ selectedAppointment,
+ persistDraft,
+ persistLabCases,
+ showSuccess,
+ showError,
+ t,
+ ]);
+
+ const handleSendLabCase = useCallback(
+ async (labCase: LabCaseDraft) => {
if (!canEditTreatmentForDay || !selectedAppointment) return;
- const destinationOrgId = treatmentCase.sendToOrganizationIds.find((id) =>
- orgs.some((o) => o.id === id && o.active),
- );
- if (!destinationOrgId) {
+ if (!labCase.destinationOrganizationId) {
showError(t('errorChooseOrg'));
return;
}
- setSendBusyId(treatmentCase.clientId);
+ if (labCase.detailClientIds.length === 0) {
+ showError(t('errorLabCaseNeedsDetails'));
+ return;
+ }
+
+ setSendBusyId(labCase.clientId);
try {
const saved = await persistDraft();
- const serverDetail = saved.details.find((c) => c.clientId === treatmentCase.clientId);
- if (!serverDetail?.id) throw new Error(t('errorCaseMustSave'));
+ const afterLabCases = await persistLabCases(saved);
- 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 refreshedLabCase = afterLabCases.labCases.find(
+ (lc) => lc.clientId === labCase.clientId || lc.id === labCase.id,
);
+ if (!refreshedLabCase?.id) throw new Error(t('errorSendCase'));
- 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 response = await treatmentsApi.sendLabCase(refreshedLabCase.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);
-
- setCases((prev) => {
- const next = prev.map((c) =>
- c.clientId === treatmentCase.clientId
+ setLabCaseDrafts((prev) =>
+ prev.map((lc) =>
+ lc.clientId === labCase.clientId
? {
- ...c,
- labCaseId: response.data.id,
+ ...lc,
+ id: response.data.id,
sentAt: response.data.sentAt,
- sendToOrganizationIds: response.data.destinationOrganizationId
- ? [response.data.destinationOrganizationId]
- : [],
+ destinationOrganizationId: response.data.destinationOrganizationId,
sends: response.data.sends,
}
- : c,
- );
- setSavedSnapshot(serializeCases(next));
- return next;
- });
+ : lc,
+ ),
+ );
+
setRecentOrganizationIds((prev) => {
- const next = [destinationOrgId, ...prev.filter((id) => id !== destinationOrgId)];
- return next.slice(0, 10);
+ const orgId = labCase.destinationOrganizationId!;
+ return [orgId, ...prev.filter((id) => id !== orgId)].slice(0, 10);
});
showSuccess(t('successCaseSent'));
} catch (error: unknown) {
@@ -495,47 +572,33 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
setSendBusyId(null);
}
},
- [canEditTreatmentForDay, selectedAppointment, orgs, persistDraft, showSuccess, showError, t],
+ [
+ canEditTreatmentForDay,
+ selectedAppointment,
+ persistDraft,
+ persistLabCases,
+ showSuccess,
+ showError,
+ t,
+ ],
);
- const openPreview = useCallback(
- (treatment: PastTreatment, mode: TreatmentPreviewMode) => {
- setPreviewTreatment(treatment);
- setPreviewMode(mode);
- setPreviewOpen(true);
- },
- [],
- );
+ const openPreview = useCallback((treatment: PastTreatment, mode: TreatmentPreviewMode) => {
+ setPreviewTreatment(treatment);
+ setPreviewMode(mode);
+ setPreviewOpen(true);
+ }, []);
const openCurrentDraftPreview = useCallback(() => {
if (!currentDraftPreview) return;
openPreview(currentDraftPreview, canEditTreatmentForDay ? 'editable' : 'readonly');
}, [currentDraftPreview, canEditTreatmentForDay, openPreview]);
- const getCaseOrgIds = useCallback(
- (caseKey: string) => cases.find((c) => c.clientId === caseKey)?.sendToOrganizationIds ?? [],
- [cases],
- );
-
- const toggleCaseOrg = useCallback((caseKey: string, orgId: string, checked: boolean) => {
- setCases((prev) =>
- prev.map((c) => {
- if (c.clientId !== caseKey || c.sentAt) return c;
- const next = new Set(c.sendToOrganizationIds);
- if (checked) next.add(orgId);
- else next.delete(orgId);
- return { ...c, sendToOrganizationIds: [...next] };
- }),
- );
- }, []);
-
if (!canView) {
return (
{t('noPermissionTitle')}
-
- {t('noPermissionBody')}
-
+
{t('noPermissionBody')}
);
}
@@ -545,7 +608,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
@@ -579,7 +642,9 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
{t('purposeLabel')}{' '}
- {t(TREATMENT_TYPE_KEYS[selectedAppointment.purpose as keyof typeof TREATMENT_TYPE_KEYS] ?? selectedAppointment.purpose)}
+ {t(
+ treatmentTypeLabelKey(selectedAppointment.purpose) as 'typeConsultation',
+ )}
@@ -598,7 +663,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
openPreview(t, 'readonly')}
+ onReviewTreatment={(item) => openPreview(item, 'readonly')}
/>
@@ -606,53 +671,73 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
{
- if (!canEditTreatmentForDay) return;
- setCases((prev) =>
- prev.map((c) => {
- if (c.clientId !== activeCaseId) return c;
- const set = new Set(c.teeth);
+ if (!canEditTreatmentForDay || isDetailLocked(activeDetail)) return;
+ setDetails((prev) =>
+ prev.map((d) => {
+ if (d.clientId !== activeDetailId) return d;
+ const set = new Set(d.teeth);
if (set.has(fdi)) set.delete(fdi);
else set.add(fdi);
- return { ...c, teeth: [...set].sort() as FdiToothId[] };
+ return { ...d, teeth: [...set].sort() as FdiToothId[] };
}),
);
}}
- disabled={!canEditTreatmentForDay}
+ disabled={!canEditTreatmentForDay || isDetailLocked(activeDetail)}
/>
- {
+ const next = newDetail();
+ setDetails((prev) => [...prev, next]);
+ setActiveDetailId(next.clientId);
+ }}
+ onPreview={openCurrentDraftPreview}
+ onSave={() => void handleSaveAll()}
+ onUploadFiles={(files) => void uploadForDetail(activeDetailId, files ?? [])}
+ />
+
+ {
- setCases((prev) =>
- prev.map((c) => {
- if (c.clientId !== activeCaseId || c.sentAt) return c;
- if (c.sendToOrganizationIds.includes(orgId)) return c;
- return { ...c, sendToOrganizationIds: [...c.sendToOrganizationIds, orgId] };
- }),
+ if (!activeLabCaseId) return;
+ setLabCaseDrafts((prev) =>
+ prev.map((lc) =>
+ lc.clientId === activeLabCaseId && !lc.sentAt
+ ? { ...lc, destinationOrganizationId: orgId }
+ : lc,
+ ),
);
}}
- onAddCase={() => {
- const nextCase = newCase();
- setCases((prev) => [...prev, nextCase]);
- setActiveCaseId(nextCase.clientId);
+ sendBusyId={sendBusyId}
+ saveLabBusy={saveLabBusy}
+ onAddLabCase={() => {
+ const next = newLabCaseDraft();
+ setLabCaseDrafts((prev) => [...prev, next]);
+ setActiveLabCaseId(next.clientId);
}}
- onPreview={openCurrentDraftPreview}
- onSave={() => void handleSaveAll()}
- onSendCase={(c) => void handleSendCase(c)}
- onUploadFiles={(files) => void uploadForCase(activeCaseId, files ?? [])}
+ onSaveLabCases={() => void handleSaveLabCases()}
+ onSendLabCase={(lc) => void handleSendLabCase(lc)}
/>
@@ -665,16 +750,8 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
}
mode={previewMode}
orgs={orgs}
- sendBusyCaseId={sendBusyId}
- uploadBusyCaseId={uploadBusyCaseId}
- onAttach={(caseKey, files) => uploadForCase(caseKey, files)}
- onSend={(caseKey, organizationIds) => {
- const c = cases.find((item) => item.clientId === caseKey);
- if (!c) return;
- void handleSendCase({ ...c, sendToOrganizationIds: organizationIds });
- }}
- getCaseOrgIds={getCaseOrgIds}
- onToggleCaseOrg={toggleCaseOrg}
+ uploadBusyCaseId={uploadBusyDetailId}
+ onAttach={(caseKey, files) => uploadForDetail(caseKey, files)}
/>
);
diff --git a/frontend/src/components/ui/treatment/treatmentTypeDisplay.ts b/frontend/src/components/ui/treatment/treatmentTypeDisplay.ts
new file mode 100644
index 0000000..5e5d16f
--- /dev/null
+++ b/frontend/src/components/ui/treatment/treatmentTypeDisplay.ts
@@ -0,0 +1,19 @@
+export const TREATMENT_TYPE_KEYS = {
+ consultation: 'typeConsultation',
+ filling: 'typeFilling',
+ endo: 'typeEndo',
+ visit: 'typeVisit',
+ hygiene: 'typeHygiene',
+} as const;
+
+export const TREATMENT_TYPE_COLORS: Record = {
+ consultation: '#ddd6fe',
+ filling: '#fed7aa',
+ endo: '#fecaca',
+ visit: '#bae6fd',
+ hygiene: '#d9f99d',
+};
+
+export function treatmentTypeLabelKey(code: string): string {
+ return TREATMENT_TYPE_KEYS[code as keyof typeof TREATMENT_TYPE_KEYS] ?? code;
+}
diff --git a/frontend/src/lib/api/treatment-catalog.ts b/frontend/src/lib/api/treatment-catalog.ts
new file mode 100644
index 0000000..001d9fd
--- /dev/null
+++ b/frontend/src/lib/api/treatment-catalog.ts
@@ -0,0 +1,9 @@
+import { apiClient } from './client';
+import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
+
+export const treatmentCatalogApi = {
+ list: async (): Promise<{ success: boolean; data: TreatmentCatalogEntry[] }> => {
+ const response = await apiClient.get('/treatment-catalog');
+ return response.data;
+ },
+};
diff --git a/frontend/src/types/treatment-catalog.ts b/frontend/src/types/treatment-catalog.ts
new file mode 100644
index 0000000..5ecb3d8
--- /dev/null
+++ b/frontend/src/types/treatment-catalog.ts
@@ -0,0 +1,6 @@
+export interface TreatmentCatalogEntry {
+ id: string;
+ code: string;
+ labDependent: boolean;
+ sortOrder: number;
+}
diff --git a/frontend/src/types/treatment.ts b/frontend/src/types/treatment.ts
index ca00506..c511b29 100644
--- a/frontend/src/types/treatment.ts
+++ b/frontend/src/types/treatment.ts
@@ -136,6 +136,16 @@ export interface TreatmentDetailDraft {
/** @deprecated Use TreatmentDetailDraft — kept for editor components until Phase 4 rename */
export type TreatmentCaseDraft = TreatmentDetailDraft;
+export interface LabCaseDraft {
+ clientId: string;
+ id?: string;
+ destinationOrganizationId: string | null;
+ labComment: string;
+ detailClientIds: string[];
+ sentAt?: string | null;
+ sends?: LabCaseSendInfo[];
+}
+
export type SavedTreatmentDetailPayload = {
clientId: string;
id?: string;
--
2.53.0.windows.1
From 478cfa085ad8be1cb0b9cccf3c4312ff2e9190a7 Mon Sep 17 00:00:00 2001
From: Admin
Date: Sun, 28 Jun 2026 17:49:40 +0330
Subject: [PATCH 06/17] feature: Phase5 - Lab Cases inbox + task board
---
backend/src/modules/cases/cases.controller.ts | 7 +
backend/src/modules/cases/cases.service.ts | 102 +++++-
backend/src/modules/cases/dto/cases.dto.ts | 12 +-
frontend/messages/en.json | 14 +-
frontend/messages/fa.json | 14 +-
frontend/messages/nl.json | 14 +-
.../app/[locale]/(dashboard)/cases/page.tsx | 338 ++++++++++++++----
frontend/src/lib/api/cases.ts | 6 +
frontend/src/types/cases.ts | 7 +
9 files changed, 431 insertions(+), 83 deletions(-)
diff --git a/backend/src/modules/cases/cases.controller.ts b/backend/src/modules/cases/cases.controller.ts
index 2216e85..7c3a967 100644
--- a/backend/src/modules/cases/cases.controller.ts
+++ b/backend/src/modules/cases/cases.controller.ts
@@ -28,6 +28,13 @@ export class CasesController {
return this.casesService.list(organizationId, req.user.id, query);
}
+ @Get('filter-options')
+ @ApiOperation({ summary: 'Clinics and treatment types for inbox filters' })
+ listFilterOptions(@Req() req) {
+ const organizationId = this.casesService.getOrganizationIdFromUser(req.user);
+ return this.casesService.listFilterOptions(organizationId, req.user.id);
+ }
+
@Get('assignable-members')
@ApiOperation({ summary: 'List lab staff who can be assigned to tasks' })
listAssignableMembers(@Req() req) {
diff --git a/backend/src/modules/cases/cases.service.ts b/backend/src/modules/cases/cases.service.ts
index 924ed55..2ea727a 100644
--- a/backend/src/modules/cases/cases.service.ts
+++ b/backend/src/modules/cases/cases.service.ts
@@ -72,23 +72,7 @@ export class CasesService {
const limit = Math.min(Math.max(query.limit ?? 20, 1), 100);
const skip = (page - 1) * limit;
- const where: Prisma.LabCaseWhereInput = {
- sentAt: { not: null },
- sends: { some: { organizationId: labOrganizationId } },
- ...(query.clinicOrganizationId
- ? { treatment: { organizationId: query.clinicOrganizationId } }
- : {}),
- ...(query.treatmentType
- ? {
- details: {
- some: { detail: { treatmentType: query.treatmentType } },
- },
- }
- : {}),
- ...(query.q?.trim()
- ? this.buildSearchWhere(query.q.trim())
- : {}),
- };
+ const where = this.buildListWhere(labOrganizationId, query);
const [items, total] = await Promise.all([
this.prisma.labCase.findMany({
@@ -128,6 +112,50 @@ export class CasesService {
};
}
+ async listFilterOptions(labOrganizationId: string, actorUserId: string) {
+ await this.assertCanReadCases(actorUserId, labOrganizationId);
+
+ const rows = await this.prisma.labCase.findMany({
+ where: {
+ sentAt: { not: null },
+ sends: { some: { organizationId: labOrganizationId } },
+ },
+ select: {
+ treatment: {
+ select: {
+ organization: { select: { id: true, name: true } },
+ },
+ },
+ details: {
+ select: { detail: { select: { treatmentType: true } } },
+ },
+ },
+ });
+
+ const clinicsById = new Map();
+ const typeCodes = new Set();
+
+ for (const row of rows) {
+ clinicsById.set(row.treatment.organization.id, row.treatment.organization);
+ for (const link of row.details) {
+ typeCodes.add(link.detail.treatmentType);
+ }
+ }
+
+ const treatmentTypes = this.treatmentCatalog
+ .list()
+ .filter((entry) => entry.labDependent && typeCodes.has(entry.code))
+ .map((entry) => ({ code: entry.code, labDependent: entry.labDependent }));
+
+ return {
+ success: true,
+ data: {
+ clinics: [...clinicsById.values()].sort((a, b) => a.name.localeCompare(b.name)),
+ treatmentTypes,
+ },
+ };
+ }
+
async getOne(labCaseId: string, labOrganizationId: string, actorUserId: string) {
await this.assertCanReadCases(actorUserId, labOrganizationId);
@@ -209,6 +237,46 @@ export class CasesService {
};
}
+ private buildListWhere(
+ labOrganizationId: string,
+ query: ListLabCasesDto,
+ ): Prisma.LabCaseWhereInput {
+ const sentAtFilter: Prisma.DateTimeNullableFilter = { not: null };
+
+ if (query.sentFrom) {
+ const from = new Date(query.sentFrom);
+ if (Number.isNaN(from.getTime())) {
+ throw new BadRequestException('Invalid sentFrom date');
+ }
+ sentAtFilter.gte = from;
+ }
+
+ if (query.sentTo) {
+ const to = new Date(query.sentTo);
+ if (Number.isNaN(to.getTime())) {
+ throw new BadRequestException('Invalid sentTo date');
+ }
+ to.setHours(23, 59, 59, 999);
+ sentAtFilter.lte = to;
+ }
+
+ return {
+ sentAt: sentAtFilter,
+ sends: { some: { organizationId: labOrganizationId } },
+ ...(query.clinicOrganizationId
+ ? { treatment: { organizationId: query.clinicOrganizationId } }
+ : {}),
+ ...(query.treatmentType
+ ? {
+ details: {
+ some: { detail: { treatmentType: query.treatmentType } },
+ },
+ }
+ : {}),
+ ...(query.q?.trim() ? this.buildSearchWhere(query.q.trim()) : {}),
+ };
+ }
+
private buildSearchWhere(q: string): Prisma.LabCaseWhereInput {
const orConditions: Prisma.LabCaseWhereInput[] = [
{
diff --git a/backend/src/modules/cases/dto/cases.dto.ts b/backend/src/modules/cases/dto/cases.dto.ts
index 331b798..438da73 100644
--- a/backend/src/modules/cases/dto/cases.dto.ts
+++ b/backend/src/modules/cases/dto/cases.dto.ts
@@ -1,5 +1,5 @@
import { Transform } from 'class-transformer';
-import { IsEnum, IsInt, IsOptional, IsString, IsUUID, Max, Min, ValidateIf } from 'class-validator';
+import { IsDateString, IsEnum, IsInt, IsOptional, IsString, IsUUID, Max, Min, ValidateIf } from 'class-validator';
import { LabTaskStatus } from '@prisma/client';
export class UpdateLabCaseTaskDto {
@@ -19,13 +19,21 @@ export class ListLabCasesDto {
q?: string;
@IsOptional()
- @IsString()
+ @IsUUID()
clinicOrganizationId?: string;
@IsOptional()
@IsString()
treatmentType?: string;
+ @IsOptional()
+ @IsDateString()
+ sentFrom?: string;
+
+ @IsOptional()
+ @IsDateString()
+ sentTo?: string;
+
@IsOptional()
@Transform(({ value }) => Number(value))
@IsInt()
diff --git a/frontend/messages/en.json b/frontend/messages/en.json
index 3f889c3..72695a5 100644
--- a/frontend/messages/en.json
+++ b/frontend/messages/en.json
@@ -336,7 +336,19 @@
"statusCompleted": "Completed",
"errorLoadList": "Failed to load cases.",
"errorLoadDetail": "Failed to load case details.",
- "errorUpdateTask": "Failed to update task."
+ "errorUpdateTask": "Failed to update task.",
+ "filterClinic": "Clinic",
+ "filterClinicAll": "All clinics",
+ "filterTreatmentType": "Treatment type",
+ "filterTreatmentTypeAll": "All types",
+ "filterSentFrom": "Sent from",
+ "filterSentTo": "Sent to",
+ "clearFilters": "Clear filters",
+ "patientMobile": "Mobile",
+ "labComment": "Lab comment",
+ "prevPage": "Previous",
+ "nextPage": "Next",
+ "pageSummary": "Page {page} of {totalPages} ({total} cases)"
},
"appointments": {
"title": "Appointments",
diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json
index c8394cb..6c0560f 100644
--- a/frontend/messages/fa.json
+++ b/frontend/messages/fa.json
@@ -336,7 +336,19 @@
"statusCompleted": "انجام شده",
"errorLoadList": "بارگذاری پروندهها ناموفق بود.",
"errorLoadDetail": "بارگذاری جزئیات پرونده ناموفق بود.",
- "errorUpdateTask": "بهروزرسانی وظیفه ناموفق بود."
+ "errorUpdateTask": "بهروزرسانی وظیفه ناموفق بود.",
+ "filterClinic": "کلینیک",
+ "filterClinicAll": "همه کلینیکها",
+ "filterTreatmentType": "نوع درمان",
+ "filterTreatmentTypeAll": "همه انواع",
+ "filterSentFrom": "ارسال از",
+ "filterSentTo": "ارسال تا",
+ "clearFilters": "پاک کردن فیلترها",
+ "patientMobile": "موبایل",
+ "labComment": "یادداشت آزمایشگاه",
+ "prevPage": "قبلی",
+ "nextPage": "بعدی",
+ "pageSummary": "صفحه {page} از {totalPages} ({total} پرونده)"
},
"appointments": {
"title": "نوبتها",
diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json
index 16ea5b7..c22dbfb 100644
--- a/frontend/messages/nl.json
+++ b/frontend/messages/nl.json
@@ -336,7 +336,19 @@
"statusCompleted": "Voltooid",
"errorLoadList": "Dossiers laden mislukt.",
"errorLoadDetail": "Dossierdetails laden mislukt.",
- "errorUpdateTask": "Taak bijwerken mislukt."
+ "errorUpdateTask": "Taak bijwerken mislukt.",
+ "filterClinic": "Kliniek",
+ "filterClinicAll": "Alle klinieken",
+ "filterTreatmentType": "Behandeltype",
+ "filterTreatmentTypeAll": "Alle types",
+ "filterSentFrom": "Verzonden vanaf",
+ "filterSentTo": "Verzonden tot",
+ "clearFilters": "Filters wissen",
+ "patientMobile": "Mobiel",
+ "labComment": "Labnotitie",
+ "prevPage": "Vorige",
+ "nextPage": "Volgende",
+ "pageSummary": "Pagina {page} van {totalPages} ({total} dossiers)"
},
"appointments": {
"title": "Afspraken",
diff --git a/frontend/src/app/[locale]/(dashboard)/cases/page.tsx b/frontend/src/app/[locale]/(dashboard)/cases/page.tsx
index 49657df..207b4c4 100644
--- a/frontend/src/app/[locale]/(dashboard)/cases/page.tsx
+++ b/frontend/src/app/[locale]/(dashboard)/cases/page.tsx
@@ -8,7 +8,16 @@ import { useAuth } from '@/lib/hooks/useAuth';
import { useToast } from '@/lib/hooks/useToast';
import { hasPermission } from '@/components/shared/permissions';
import { casesApi } from '@/lib/api/cases';
-import type { AssignableMember, LabCaseDetail, LabCaseListItem, LabTaskStatus } from '@/types/cases';
+import { Button } from '@/components/ui/shared/Button';
+import { SearchBar } from '@/components/ui/shared/SearchBar';
+import type {
+ AssignableMember,
+ CasesFilterOptions,
+ LabCaseDetail,
+ LabCaseListItem,
+ LabTaskStatus,
+ PaginatedLabCases,
+} from '@/types/cases';
const TREATMENT_TYPE_KEYS = {
consultation: 'typeConsultation',
@@ -18,6 +27,8 @@ const TREATMENT_TYPE_KEYS = {
hygiene: 'typeHygiene',
} as const;
+const PAGE_SIZE = 20;
+
function formatPatientName(patient: { firstName: string; lastName: string }) {
return `${patient.firstName} ${patient.lastName}`.trim();
}
@@ -30,6 +41,25 @@ function formatDateTime(value: string | null, locale: string) {
}).format(new Date(value));
}
+function TaskProgressBar({ completed, total }: { completed: number; total: number }) {
+ const pct = total > 0 ? Math.round((completed / total) * 100) : 0;
+
+ return (
+
+
+ {completed}/{total}
+ {pct}%
+
+
+
+ );
+}
+
export default function CasesPage() {
const t = useTranslations('cases');
const tTreatment = useTranslations('treatment');
@@ -38,7 +68,24 @@ export default function CasesPage() {
const toast = useToast();
const [search, setSearch] = useState('');
+ const [clinicId, setClinicId] = useState('');
+ const [treatmentType, setTreatmentType] = useState('');
+ const [sentFrom, setSentFrom] = useState('');
+ const [sentTo, setSentTo] = useState('');
+ const [page, setPage] = useState(1);
+
const [cases, setCases] = useState([]);
+ const [pagination, setPagination] = useState({
+ page: 1,
+ limit: PAGE_SIZE,
+ total: 0,
+ totalPages: 1,
+ });
+ const [filterOptions, setFilterOptions] = useState({
+ clinics: [],
+ treatmentTypes: [],
+ });
+
const [selectedCaseId, setSelectedCaseId] = useState(null);
const [selectedCase, setSelectedCase] = useState(null);
const [members, setMembers] = useState([]);
@@ -66,12 +113,32 @@ export default function CasesPage() {
[t],
);
- const loadCases = async (q: string) => {
+ const hasActiveFilters = Boolean(
+ search.trim() || clinicId || treatmentType || sentFrom || sentTo,
+ );
+
+ const loadCases = async (params: {
+ q: string;
+ clinicOrganizationId: string;
+ treatmentType: string;
+ sentFrom: string;
+ sentTo: string;
+ page: number;
+ }) => {
setLoadingList(true);
toast.setError('');
try {
- const response = await casesApi.list({ q: q.trim() || undefined, page: 1, limit: 50 });
+ const response = await casesApi.list({
+ q: params.q.trim() || undefined,
+ clinicOrganizationId: params.clinicOrganizationId || undefined,
+ treatmentType: params.treatmentType || undefined,
+ sentFrom: params.sentFrom || undefined,
+ sentTo: params.sentTo || undefined,
+ page: params.page,
+ limit: PAGE_SIZE,
+ });
setCases(response.data.items);
+ setPagination(response.data.pagination);
} catch (error: unknown) {
toast.showError(formatApiErrorMessage(error, t('errorLoadList')));
} finally {
@@ -94,18 +161,25 @@ export default function CasesPage() {
};
useEffect(() => {
- void loadCases('');
+ void casesApi.listFilterOptions().then((r) => setFilterOptions(r.data)).catch(() => {});
void casesApi.listAssignableMembers().then((r) => setMembers(r.data)).catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only initial fetch
}, []);
useEffect(() => {
const timeout = setTimeout(() => {
- void loadCases(search);
- }, 300);
+ void loadCases({
+ q: search,
+ clinicOrganizationId: clinicId,
+ treatmentType,
+ sentFrom,
+ sentTo,
+ page,
+ });
+ }, search ? 300 : 0);
return () => clearTimeout(timeout);
- // eslint-disable-next-line react-hooks/exhaustive-deps -- debounced search only
- }, [search]);
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- debounced search + filter reload
+ }, [search, clinicId, treatmentType, sentFrom, sentTo, page]);
useEffect(() => {
if (selectedCaseId) {
@@ -116,6 +190,15 @@ export default function CasesPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps -- reload when selection changes
}, [selectedCaseId]);
+ function clearFilters() {
+ setSearch('');
+ setClinicId('');
+ setTreatmentType('');
+ setSentFrom('');
+ setSentTo('');
+ setPage(1);
+ }
+
async function handleTaskUpdate(
taskId: string,
payload: { assigneeUserId?: string | null; status?: LabTaskStatus },
@@ -127,7 +210,14 @@ export default function CasesPage() {
try {
await casesApi.updateTask(selectedCaseId, taskId, payload);
await loadDetail(selectedCaseId);
- await loadCases(search);
+ await loadCases({
+ q: search,
+ clinicOrganizationId: clinicId,
+ treatmentType,
+ sentFrom,
+ sentTo,
+ page,
+ });
} catch (error: unknown) {
toast.showError(formatApiErrorMessage(error, t('errorUpdateTask')));
} finally {
@@ -135,6 +225,9 @@ export default function CasesPage() {
}
}
+ const filterSelectClass =
+ 'w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-text-primary';
+
return (
@@ -142,57 +235,165 @@ export default function CasesPage() {
{t('subtitle')}
-
-
-
+
+ setSearch(e.target.value)}
+ onChange={(value) => {
+ setSearch(value);
+ setPage(1);
+ }}
placeholder={t('searchPlaceholder')}
- className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
/>
- {loadingList ? (
- {tCommon('loading')}
- ) : cases.length === 0 ? (
- {t('emptyList')}
- ) : (
-
- {cases.map((item) => {
- const isActive = item.id === selectedCaseId;
- const progress =
- item.taskProgress.total > 0
- ? `${item.taskProgress.completed}/${item.taskProgress.total}`
- : '0/0';
+
+
+ {t('filterClinic')}
+ {
+ setClinicId(e.target.value);
+ setPage(1);
+ }}
+ className={filterSelectClass}
+ >
+ {t('filterClinicAll')}
+ {filterOptions.clinics.map((clinic) => (
+
+ {clinic.name}
+
+ ))}
+
+
- return (
-
- setSelectedCaseId(item.id)}
- className={`w-full rounded-md border px-3 py-2 text-left transition-colors ${
- isActive
- ? 'border-primary bg-primary/5'
- : 'border-border hover:border-primary/40'
- }`}
- >
-
- {formatPatientName(item.patient)}
-
- {item.clinic.name}
-
- {formatDateTime(item.sentAt, locale)}
- {t('taskProgressShort', { progress })}
-
-
- {item.treatmentTypes.map(treatmentLabel).join(', ')}
-
-
-
- );
- })}
-
- )}
+
+ {t('filterTreatmentType')}
+ {
+ setTreatmentType(e.target.value);
+ setPage(1);
+ }}
+ className={filterSelectClass}
+ >
+ {t('filterTreatmentTypeAll')}
+ {filterOptions.treatmentTypes.map((type) => (
+
+ {treatmentLabel(type.code)}
+
+ ))}
+
+
+
+
+ {t('filterSentFrom')}
+ {
+ setSentFrom(e.target.value);
+ setPage(1);
+ }}
+ className={filterSelectClass}
+ />
+
+
+
+ {t('filterSentTo')}
+ {
+ setSentTo(e.target.value);
+ setPage(1);
+ }}
+ className={filterSelectClass}
+ />
+
+
+
+ {hasActiveFilters ? (
+
+ {t('clearFilters')}
+
+ ) : null}
+
+
+ {loadingList ? (
+
{tCommon('loading')}
+ ) : cases.length === 0 ? (
+
{t('emptyList')}
+ ) : (
+
+ {cases.map((item) => {
+ const isActive = item.id === selectedCaseId;
+
+ return (
+
+ setSelectedCaseId(item.id)}
+ className={`w-full rounded-md border px-3 py-2.5 text-left transition-colors ${
+ isActive
+ ? 'border-primary bg-primary/5'
+ : 'border-border hover:border-primary/40'
+ }`}
+ >
+
+ {formatPatientName(item.patient)}
+
+
+ {item.patient.mobile}
+
+ {item.clinic.name}
+
+ {formatDateTime(item.sentAt, locale)}
+
+
+ {item.treatmentTypes.map(treatmentLabel).join(', ')}
+
+
+
+
+
+
+ );
+ })}
+
+ )}
+
+
+ {pagination.totalPages > 1 ? (
+
+ setPage((p) => Math.max(1, p - 1))}
+ >
+ {t('prevPage')}
+
+
+ {t('pageSummary', {
+ page: pagination.page,
+ totalPages: pagination.totalPages,
+ total: pagination.total,
+ })}
+
+ = pagination.totalPages || loadingList}
+ onClick={() => setPage((p) => p + 1)}
+ >
+ {t('nextPage')}
+
+
+ ) : null}
@@ -206,18 +407,33 @@ export default function CasesPage() {
{formatPatientName(selectedCase.patient)}
+
+ {t('patientMobile')}: {selectedCase.patient.mobile}
+
{t('fromClinic', { name: selectedCase.clinic.name })}
{t('sentAt', { date: formatDateTime(selectedCase.sentAt, locale) })}
-
- {t('taskProgressLabel', {
- completed: selectedCase.taskProgress.completed,
- total: selectedCase.taskProgress.total,
- })}
-
+
+
+ {t('taskProgressLabel', {
+ completed: selectedCase.taskProgress.completed,
+ total: selectedCase.taskProgress.total,
+ })}
+
+
+
+ {selectedCase.labComment ? (
+
+ {t('labComment')}: {' '}
+ {selectedCase.labComment}
+
+ ) : null}
{selectedCase.details.length > 0 && (
diff --git a/frontend/src/lib/api/cases.ts b/frontend/src/lib/api/cases.ts
index fd6f1da..da7496b 100644
--- a/frontend/src/lib/api/cases.ts
+++ b/frontend/src/lib/api/cases.ts
@@ -1,6 +1,7 @@
import { apiClient } from './client';
import type {
AssignableMember,
+ CasesFilterOptions,
LabCaseDetail,
LabCaseTask,
ListLabCasesParams,
@@ -25,6 +26,11 @@ export const casesApi = {
return response.data;
},
+ listFilterOptions: async (): Promise<{ success: boolean; data: CasesFilterOptions }> => {
+ const response = await apiClient.get('/cases/filter-options');
+ return response.data;
+ },
+
updateTask: async (
caseId: string,
taskId: string,
diff --git a/frontend/src/types/cases.ts b/frontend/src/types/cases.ts
index fed5383..bd06ded 100644
--- a/frontend/src/types/cases.ts
+++ b/frontend/src/types/cases.ts
@@ -73,6 +73,13 @@ export interface ListLabCasesParams {
limit?: number;
clinicOrganizationId?: string;
treatmentType?: string;
+ sentFrom?: string;
+ sentTo?: string;
+}
+
+export interface CasesFilterOptions {
+ clinics: Array<{ id: string; name: string }>;
+ treatmentTypes: Array<{ code: string; labDependent: boolean }>;
}
export interface PaginatedLabCases {
--
2.53.0.windows.1
From 7b8ed48fa0baceebcbeffa782058d6c9fdf86b81 Mon Sep 17 00:00:00 2001
From: Admin
Date: Sun, 28 Jun 2026 18:08:29 +0330
Subject: [PATCH 07/17] improvement: treatment plan now save on debounce. save
treatment button removed.
---
frontend/messages/en.json | 4 +
frontend/messages/fa.json | 4 +
frontend/messages/nl.json | 4 +
.../ui/treatment/LabCasesDispatchPanel.tsx | 22 +-
.../ui/treatment/TreatmentDetailsEditor.tsx | 36 +--
.../ui/treatment/TreatmentWorkspace.tsx | 264 ++++++++++++------
6 files changed, 211 insertions(+), 123 deletions(-)
diff --git a/frontend/messages/en.json b/frontend/messages/en.json
index 72695a5..19821a4 100644
--- a/frontend/messages/en.json
+++ b/frontend/messages/en.json
@@ -463,6 +463,7 @@
"sendThisCase": "Send this case",
"labDispatchTitle": "Lab dispatch",
"labDispatchSubtitle": "Group lab-dependent details into shipments and send them to linked labs.",
+ "labDispatchSendHint": "Shipment grouping is saved when you send to the lab.",
"addLabShipment": "Add lab shipment",
"labShipmentLabel": "Shipment {n}",
"includeDetails": "Include treatment details",
@@ -482,6 +483,9 @@
"saveDraft": "Save treatment draft",
"unsavedChanges": "Unsaved changes",
"draftSaved": "Draft saved",
+ "saveStatusSaving": "Saving…",
+ "saveStatusSaved": "All changes saved",
+ "saveStatusError": "Could not save — check your connection",
"sendSavesFirst": "Sending is per case and saves first automatically.",
"historyTitle": "Previous treatments",
"historySubtitle": "Completed treatments for this patient. Each case is listed separately.",
diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json
index 6c0560f..6302772 100644
--- a/frontend/messages/fa.json
+++ b/frontend/messages/fa.json
@@ -463,6 +463,7 @@
"sendThisCase": "ارسال این پرونده",
"labDispatchTitle": "ارسال به لابراتوار",
"labDispatchSubtitle": "جزئیات وابسته به لاب را در محمولهها گروهبندی کرده و به لابراتوارهای متصل ارسال کنید.",
+ "labDispatchSendHint": "گروهبندی محموله هنگام ارسال به لاب ذخیره میشود.",
"addLabShipment": "افزودن محموله لاب",
"labShipmentLabel": "محموله {n}",
"includeDetails": "شامل جزئیات درمان",
@@ -482,6 +483,9 @@
"saveDraft": "ذخیره پیشنویس درمان",
"unsavedChanges": "تغییرات ذخیرهنشده",
"draftSaved": "پیشنویس ذخیره شد",
+ "saveStatusSaving": "در حال ذخیره…",
+ "saveStatusSaved": "همه تغییرات ذخیره شد",
+ "saveStatusError": "ذخیره ناموفق بود — اتصال را بررسی کنید",
"sendSavesFirst": "ارسال برای هر پرونده به صورت جداگانه است و ابتدا به طور خودکار ذخیره میکند.",
"historyTitle": "درمانهای قبلی",
"historySubtitle": "درمانهای تکمیل شده برای این بیمار. هر پرونده به طور جداگانه فهرست شده است.",
diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json
index c22dbfb..917473f 100644
--- a/frontend/messages/nl.json
+++ b/frontend/messages/nl.json
@@ -463,6 +463,7 @@
"sendThisCase": "Verzend deze case",
"labDispatchTitle": "Lab-dispatch",
"labDispatchSubtitle": "Groepeer lab-afhankelijke details in zendingen en stuur ze naar gekoppelde labs.",
+ "labDispatchSendHint": "Groepering wordt opgeslagen wanneer u naar het lab verzendt.",
"addLabShipment": "Labzending toevoegen",
"labShipmentLabel": "Zending {n}",
"includeDetails": "Behandeldetails opnemen",
@@ -482,6 +483,9 @@
"saveDraft": "Behandelconcept opslaan",
"unsavedChanges": "Niet-opgeslagen wijzigingen",
"draftSaved": "Concept opgeslagen",
+ "saveStatusSaving": "Opslaan…",
+ "saveStatusSaved": "Alle wijzigingen opgeslagen",
+ "saveStatusError": "Opslaan mislukt — controleer uw verbinding",
"sendSavesFirst": "Verzenden is per case en slaat eerst automatisch op.",
"historyTitle": "Eerdere behandelingen",
"historySubtitle": "Voltooide behandelingen voor deze patiënt. Elke case wordt afzonderlijk weergegeven.",
diff --git a/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx b/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx
index 068173f..f0aec59 100644
--- a/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx
+++ b/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx
@@ -25,9 +25,7 @@ interface LabCasesDispatchPanelProps {
recentOrganizationIds: string[];
onRecentOrganizationPick: (orgId: string) => void;
sendBusyId: string | null;
- saveLabBusy: boolean;
onAddLabCase: () => void;
- onSaveLabCases: () => void;
onSendLabCase: (labCase: LabCaseDraft) => void;
}
@@ -46,9 +44,7 @@ export function LabCasesDispatchPanel({
recentOrganizationIds,
onRecentOrganizationPick,
sendBusyId,
- saveLabBusy,
onAddLabCase,
- onSaveLabCases,
onSendLabCase,
}: LabCasesDispatchPanelProps) {
const t = useTranslations('treatment');
@@ -133,7 +129,9 @@ export function LabCasesDispatchPanel({
{t('labDispatchTitle')}
-
{t('labDispatchSubtitle')}
+
+ {t('labDispatchSubtitle')} {t('labDispatchSendHint')}
+
)}
- {canEdit && (
-
-
- {t('saveLabShipments')}
-
-
{t('labDispatchSaveHint')}
-
- )}
>
)}
diff --git a/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx b/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx
index 768586d..b3693fb 100644
--- a/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx
+++ b/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx
@@ -15,12 +15,10 @@ interface TreatmentDetailsEditorProps {
isDetailLocked: (detail: TreatmentDetailDraft) => boolean;
disabled: boolean;
canEdit: boolean;
- isDirty: boolean;
- saveBusy: boolean;
+ saveStatus: 'idle' | 'dirty' | 'saving' | 'saved' | 'error';
uploadBusy: boolean;
onAddDetail: () => void;
onPreview: () => void;
- onSave: () => void;
onUploadFiles: (files: FileList | null) => void;
}
@@ -32,12 +30,10 @@ export function TreatmentDetailsEditor({
isDetailLocked,
disabled,
canEdit,
- isDirty,
- saveBusy,
+ saveStatus,
uploadBusy,
onAddDetail,
onPreview,
- onSave,
onUploadFiles,
}: TreatmentDetailsEditorProps) {
const t = useTranslations('treatment');
@@ -175,21 +171,19 @@ export function TreatmentDetailsEditor({
- {canEdit && (
-
-
- {t('saveDraft')}
-
-
- {isDirty ? t('unsavedChanges') : t('draftSaved')}. {t('detailsSaveHint')}
-
-
+ {canEdit && saveStatus !== 'idle' && (
+
+ {saveStatus === 'dirty' && t('unsavedChanges')}
+ {saveStatus === 'saving' && t('saveStatusSaving')}
+ {saveStatus === 'saved' && t('saveStatusSaved')}
+ {saveStatus === 'error' && t('saveStatusError')}
+
)}
);
diff --git a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx
index 083dbff..fb6a251 100644
--- a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx
+++ b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx
@@ -122,6 +122,16 @@ function serializeDetails(details: TreatmentDetailDraft[]) {
);
}
+function isDetailsDirty(
+ details: TreatmentDetailDraft[],
+ savedSnapshot: string | null,
+): boolean {
+ if (savedSnapshot === null) {
+ return details.length !== 1 || details[0].comment !== '' || details[0].teeth.length > 0;
+ }
+ return serializeDetails(details) !== savedSnapshot;
+}
+
function detailsToPreviewTreatment(
details: TreatmentDetailDraft[],
meta: { title: string; patientId: string; treatmentAt: string; status: string; id?: string },
@@ -180,12 +190,20 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
const [activeDetailId, setActiveDetailId] = useState(() => details[0].clientId);
const [activeLabCaseId, setActiveLabCaseId] = useState(null);
const [savedSnapshot, setSavedSnapshot] = useState(null);
+ const [saveStatus, setSaveStatus] = useState<'idle' | 'dirty' | 'saving' | 'saved' | 'error'>('idle');
const selectionLockedRef = useRef(selectionLocked);
selectionLockedRef.current = selectionLocked;
- const [saveBusy, setSaveBusy] = useState(false);
- const [saveLabBusy, setSaveLabBusy] = useState(false);
+ const detailsRef = useRef(details);
+ detailsRef.current = details;
+ const savedSnapshotRef = useRef(savedSnapshot);
+ savedSnapshotRef.current = savedSnapshot;
+ const autosaveTimerRef = useRef | null>(null);
+ const saveInFlightRef = useRef(false);
+ const saveQueuedRef = useRef(false);
+ const draftHydratingRef = useRef(false);
+
const [sendBusyId, setSendBusyId] = useState(null);
const [uploadBusyDetailId, setUploadBusyDetailId] = useState(null);
const [organizationSearch, setOrganizationSearch] = useState('');
@@ -201,12 +219,12 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
[labCaseDrafts],
);
- const isDirty = useMemo(() => {
- if (savedSnapshot === null) {
- return details.length !== 1 || details[0].comment !== '' || details[0].teeth.length > 0;
- }
- return serializeDetails(details) !== savedSnapshot;
- }, [details, savedSnapshot]);
+ const isDirty = useMemo(
+ () => isDetailsDirty(details, savedSnapshot),
+ [details, savedSnapshot],
+ );
+
+ const AUTOSAVE_DEBOUNCE_MS = 600;
const selectedAppointment = useMemo(
() => appointments.find((a) => a.id === selectedAppointmentId) ?? null,
@@ -342,6 +360,12 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
if (!appointmentId) return;
let cancelled = false;
+ draftHydratingRef.current = true;
+ if (autosaveTimerRef.current) {
+ clearTimeout(autosaveTimerRef.current);
+ autosaveTimerRef.current = null;
+ }
+
void (async () => {
try {
const response = await treatmentsApi.getDraft(appointmentId);
@@ -366,37 +390,165 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
setLabCaseDrafts(mappedLabCases);
setActiveLabCaseId(mappedLabCases[0]?.clientId ?? null);
setOrganizationSearch('');
+ setSaveStatus('idle');
} catch (error: unknown) {
if (!cancelled) {
showError(formatApiErrorMessage(error, t('errorLoadDraft')));
}
+ } finally {
+ if (!cancelled) {
+ draftHydratingRef.current = false;
+ }
}
})();
return () => {
cancelled = true;
+ draftHydratingRef.current = false;
};
}, [selectedAppointment?.id, showError, t]);
- const confirmDiscardIfDirty = useCallback(() => {
- if (!isDirty) return true;
- return window.confirm(t('confirmDiscard'));
- }, [isDirty, t]);
+ const persistDraft = useCallback(
+ async (options?: { force?: boolean }) => {
+ if (!selectedAppointment) throw new Error('No appointment selected');
+
+ const currentDetails = detailsRef.current;
+ const dirty = isDetailsDirty(currentDetails, savedSnapshotRef.current);
+
+ if (!options?.force && !dirty) {
+ return detailsToPreviewTreatment(currentDetails, {
+ title: t('draftTitle', {
+ patientName: `${selectedAppointment.patientFirstName} ${selectedAppointment.patientLastName}`,
+ }),
+ patientId: selectedAppointment.patientId,
+ treatmentAt: selectedAppointment.startAt,
+ status: 'draft',
+ });
+ }
+
+ const response = await treatmentsApi.saveDraft(selectedAppointment.id, {
+ details: currentDetails.map(({ clientId, id, treatmentType, teeth, comment, attachmentMetas }) => ({
+ clientId,
+ id,
+ treatmentType,
+ teeth,
+ comment,
+ attachmentIds: attachmentMetas.map((a) => a.id),
+ })),
+ });
+ const mapped = response.data.details.map(mapDetailFromApi);
+ setDetails(mapped);
+ setActiveDetailId((prev) => {
+ const stillExists = mapped.some((d) => d.clientId === prev);
+ return stillExists ? prev : mapped[0]?.clientId ?? prev;
+ });
+ setSavedSnapshot(serializeDetails(mapped));
+ return response.data;
+ },
+ [selectedAppointment, t],
+ );
+
+ const runDraftSave = useCallback(async () => {
+ if (!selectedAppointment || saveInFlightRef.current) {
+ if (saveInFlightRef.current) saveQueuedRef.current = true;
+ return;
+ }
+
+ if (
+ !isDetailsDirty(detailsRef.current, savedSnapshotRef.current)
+ ) {
+ return;
+ }
+
+ saveInFlightRef.current = true;
+ setSaveStatus('saving');
+ try {
+ await persistDraft();
+ setSaveStatus('saved');
+ } catch (error: unknown) {
+ setSaveStatus('error');
+ showError(formatApiErrorMessage(error, t('errorSaveDraft')));
+ throw error;
+ } finally {
+ saveInFlightRef.current = false;
+ if (saveQueuedRef.current) {
+ saveQueuedRef.current = false;
+ if (isDetailsDirty(detailsRef.current, savedSnapshotRef.current)) {
+ void runDraftSave();
+ }
+ }
+ }
+ }, [selectedAppointment, persistDraft, showError, t]);
+
+ const flushDraftSave = useCallback(async (): Promise => {
+ if (autosaveTimerRef.current) {
+ clearTimeout(autosaveTimerRef.current);
+ autosaveTimerRef.current = null;
+ }
+
+ if (!selectedAppointment || !canEditTreatmentForDay) return true;
+
+ while (saveInFlightRef.current) {
+ await new Promise((resolve) => setTimeout(resolve, 50));
+ }
+
+ if (!isDetailsDirty(detailsRef.current, savedSnapshotRef.current)) {
+ return true;
+ }
+
+ try {
+ await runDraftSave();
+ return true;
+ } catch {
+ return window.confirm(t('confirmDiscard'));
+ }
+ }, [selectedAppointment, canEditTreatmentForDay, runDraftSave, t]);
+
+ useEffect(() => {
+ if (draftHydratingRef.current || !canEditTreatmentForDay || !selectedAppointment?.id) {
+ return;
+ }
+
+ if (!isDirty) {
+ return;
+ }
+
+ setSaveStatus('dirty');
+
+ if (autosaveTimerRef.current) clearTimeout(autosaveTimerRef.current);
+ autosaveTimerRef.current = setTimeout(() => {
+ autosaveTimerRef.current = null;
+ void runDraftSave();
+ }, AUTOSAVE_DEBOUNCE_MS);
+
+ return () => {
+ if (autosaveTimerRef.current) {
+ clearTimeout(autosaveTimerRef.current);
+ autosaveTimerRef.current = null;
+ }
+ };
+ }, [details, isDirty, canEditTreatmentForDay, selectedAppointment?.id, runDraftSave]);
const onPickAppointment = useCallback(
(id: string) => {
- if (!confirmDiscardIfDirty()) return;
- setSelectionLocked(true);
- setSelectedAppointmentId(id);
+ void (async () => {
+ const ok = await flushDraftSave();
+ if (!ok) return;
+ setSelectionLocked(true);
+ setSelectedAppointmentId(id);
+ })();
},
- [confirmDiscardIfDirty],
+ [flushDraftSave],
);
const onSelectDay = useCallback(
(day: Date) => {
- if (!confirmDiscardIfDirty()) return;
- setSelectedDay(day);
+ void (async () => {
+ const ok = await flushDraftSave();
+ if (!ok) return;
+ setSelectedDay(day);
+ })();
},
- [confirmDiscardIfDirty],
+ [flushDraftSave],
);
const uploadForDetail = useCallback(
@@ -429,29 +581,6 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
[canEditTreatmentForDay, selectedAppointment, showSuccess, showError, t],
);
- const persistDraft = useCallback(async () => {
- if (!selectedAppointment) throw new Error('No appointment selected');
-
- const response = await treatmentsApi.saveDraft(selectedAppointment.id, {
- details: details.map(({ clientId, id, treatmentType, teeth, comment, attachmentMetas }) => ({
- clientId,
- id,
- treatmentType,
- teeth,
- comment,
- attachmentIds: attachmentMetas.map((a) => a.id),
- })),
- });
- const mapped = response.data.details.map(mapDetailFromApi);
- setDetails(mapped);
- setActiveDetailId((prev) => {
- const stillExists = mapped.some((d) => d.clientId === prev);
- return stillExists ? prev : mapped[0]?.clientId ?? prev;
- });
- setSavedSnapshot(serializeDetails(mapped));
- return response.data;
- }, [details, selectedAppointment]);
-
const persistLabCases = useCallback(
async (savedTreatment: PastTreatment) => {
if (!selectedAppointment) throw new Error('No appointment selected');
@@ -488,41 +617,6 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
[labCaseDrafts, selectedAppointment],
);
- const handleSaveAll = useCallback(async () => {
- if (!canEditTreatmentForDay || !selectedAppointment) return;
- setSaveBusy(true);
- try {
- await persistDraft();
- showSuccess(t('successDraftSaved'));
- } catch (error: unknown) {
- showError(formatApiErrorMessage(error, t('errorSaveDraft')));
- } finally {
- setSaveBusy(false);
- }
- }, [canEditTreatmentForDay, selectedAppointment, persistDraft, showSuccess, showError, t]);
-
- const handleSaveLabCases = useCallback(async () => {
- if (!canEditTreatmentForDay || !selectedAppointment) return;
- setSaveLabBusy(true);
- try {
- const saved = await persistDraft();
- await persistLabCases(saved);
- showSuccess(t('successLabShipmentsSaved'));
- } catch (error: unknown) {
- showError(formatApiErrorMessage(error, t('errorSaveLabShipments')));
- } finally {
- setSaveLabBusy(false);
- }
- }, [
- canEditTreatmentForDay,
- selectedAppointment,
- persistDraft,
- persistLabCases,
- showSuccess,
- showError,
- t,
- ]);
-
const handleSendLabCase = useCallback(
async (labCase: LabCaseDraft) => {
if (!canEditTreatmentForDay || !selectedAppointment) return;
@@ -537,7 +631,15 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
setSendBusyId(labCase.clientId);
try {
- const saved = await persistDraft();
+ if (autosaveTimerRef.current) {
+ clearTimeout(autosaveTimerRef.current);
+ autosaveTimerRef.current = null;
+ }
+ while (saveInFlightRef.current) {
+ await new Promise((resolve) => setTimeout(resolve, 50));
+ }
+
+ const saved = await persistDraft({ force: true });
const afterLabCases = await persistLabCases(saved);
const refreshedLabCase = afterLabCases.labCases.find(
@@ -693,8 +795,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
isDetailLocked={isDetailLocked}
disabled={!canEditTreatmentForDay}
canEdit={canEdit}
- isDirty={isDirty}
- saveBusy={saveBusy}
+ saveStatus={saveStatus}
uploadBusy={uploadBusyDetailId === activeDetailId}
onAddDetail={() => {
const next = newDetail();
@@ -702,7 +803,6 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
setActiveDetailId(next.clientId);
}}
onPreview={openCurrentDraftPreview}
- onSave={() => void handleSaveAll()}
onUploadFiles={(files) => void uploadForDetail(activeDetailId, files ?? [])}
/>
@@ -730,13 +830,11 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
);
}}
sendBusyId={sendBusyId}
- saveLabBusy={saveLabBusy}
onAddLabCase={() => {
const next = newLabCaseDraft();
setLabCaseDrafts((prev) => [...prev, next]);
setActiveLabCaseId(next.clientId);
}}
- onSaveLabCases={() => void handleSaveLabCases()}
onSendLabCase={(lc) => void handleSendLabCase(lc)}
/>
--
2.53.0.windows.1
From 94b97aa0fa25c57e61ad1c301fbb77a5071003f0 Mon Sep 17 00:00:00 2001
From: Admin
Date: Sun, 28 Jun 2026 18:48:06 +0330
Subject: [PATCH 08/17] improvement: lab case dispatch UI improved.
---
frontend/messages/en.json | 3 +
frontend/messages/fa.json | 3 +
frontend/messages/nl.json | 3 +
.../ui/treatment/LabCasesDispatchPanel.tsx | 359 ++++++++++++------
4 files changed, 247 insertions(+), 121 deletions(-)
diff --git a/frontend/messages/en.json b/frontend/messages/en.json
index 19821a4..5836698 100644
--- a/frontend/messages/en.json
+++ b/frontend/messages/en.json
@@ -467,6 +467,9 @@
"addLabShipment": "Add lab shipment",
"labShipmentLabel": "Shipment {n}",
"includeDetails": "Include treatment details",
+ "labShipmentIncludedDetails": "Included in this shipment",
+ "labShipmentNoIncludedDetails": "No details were included in this shipment.",
+ "labShipmentNoDetailsAvailable": "All lab details are already in other shipments or have been sent.",
"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.",
diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json
index 6302772..c5560b2 100644
--- a/frontend/messages/fa.json
+++ b/frontend/messages/fa.json
@@ -467,6 +467,9 @@
"addLabShipment": "افزودن محموله لاب",
"labShipmentLabel": "محموله {n}",
"includeDetails": "شامل جزئیات درمان",
+ "labShipmentIncludedDetails": "شامل این محموله",
+ "labShipmentNoIncludedDetails": "جزئیاتی در این محموله گنجانده نشده است.",
+ "labShipmentNoDetailsAvailable": "همه جزئیات لاب در محمولههای دیگر هستند یا ارسال شدهاند.",
"labDetailLine": "جزئیات {n} · {type} · {teeth}",
"noLabDetails": "هنوز جزئیات وابسته به لاب وجود ندارد. نوع لاب (مثلاً اندو) در جزئیات درمان بالا اضافه کنید.",
"labDispatchEmpty": "یک محموله لاب اضافه کنید تا جزئیات را گروهبندی و ارسال کنید.",
diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json
index 917473f..56a2e0f 100644
--- a/frontend/messages/nl.json
+++ b/frontend/messages/nl.json
@@ -467,6 +467,9 @@
"addLabShipment": "Labzending toevoegen",
"labShipmentLabel": "Zending {n}",
"includeDetails": "Behandeldetails opnemen",
+ "labShipmentIncludedDetails": "Opgenomen in deze zending",
+ "labShipmentNoIncludedDetails": "Geen details opgenomen in deze zending.",
+ "labShipmentNoDetailsAvailable": "Alle labdetails zitten al in andere zendingen of zijn verzonden.",
"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.",
diff --git a/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx b/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx
index f0aec59..a2ab79c 100644
--- a/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx
+++ b/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx
@@ -1,5 +1,6 @@
'use client';
+import { useMemo } from 'react';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/shared/Button';
import { Checkbox } from '@/components/ui/shared/Checkbox';
@@ -29,6 +30,65 @@ interface LabCasesDispatchPanelProps {
onSendLabCase: (labCase: LabCaseDraft) => void;
}
+function sentDetailClientIds(labCases: LabCaseDraft[]): Set {
+ const ids = new Set();
+ for (const lc of labCases) {
+ if (!lc.sentAt) continue;
+ for (const id of lc.detailClientIds) ids.add(id);
+ }
+ return ids;
+}
+
+function detailInOtherDraftShipment(
+ detailClientId: string,
+ labCases: LabCaseDraft[],
+ activeLabCaseClientId: string,
+): boolean {
+ return labCases.some(
+ (lc) =>
+ !lc.sentAt &&
+ lc.clientId !== activeLabCaseClientId &&
+ lc.detailClientIds.includes(detailClientId),
+ );
+}
+
+/** Lab-dependent details not yet sent to any lab. */
+function unsentLabDetails(
+ details: TreatmentDetailDraft[],
+ labCases: LabCaseDraft[],
+ labDependentCodes: Set,
+): TreatmentDetailDraft[] {
+ const sent = sentDetailClientIds(labCases);
+ return details.filter((d) => labDependentCodes.has(d.treatmentType) && !sent.has(d.clientId));
+}
+
+/** Unsent lab details not already assigned to another draft shipment. */
+function detailsAvailableForNewShipment(
+ details: TreatmentDetailDraft[],
+ labCases: LabCaseDraft[],
+ labDependentCodes: Set,
+): TreatmentDetailDraft[] {
+ return unsentLabDetails(details, labCases, labDependentCodes).filter(
+ (d) => !detailInOtherDraftShipment(d.clientId, labCases, ''),
+ );
+}
+
+/** Details the user can pick for the active draft shipment. */
+function selectableDetailsForDraftShipment(
+ details: TreatmentDetailDraft[],
+ labCases: LabCaseDraft[],
+ labDependentCodes: Set,
+ activeLabCase: LabCaseDraft,
+): TreatmentDetailDraft[] {
+ const sent = sentDetailClientIds(labCases);
+ return details.filter((d) => {
+ if (!labDependentCodes.has(d.treatmentType)) return false;
+ if (sent.has(d.clientId)) return false;
+ if (activeLabCase.detailClientIds.includes(d.clientId)) return true;
+ return !detailInOtherDraftShipment(d.clientId, labCases, activeLabCase.clientId);
+ });
+}
+
export function LabCasesDispatchPanel({
details,
labCases,
@@ -58,19 +118,37 @@ export function LabCasesDispatchPanel({
.map((id) => activeLinkedOrganizations.find((o) => o.id === id))
.filter(Boolean) as LinkedOrganizationOption[];
- const labEligibleDetails = details.filter((d) => labDependentCodes.has(d.treatmentType));
+ const labEligibleDetails = useMemo(
+ () => details.filter((d) => labDependentCodes.has(d.treatmentType)),
+ [details, labDependentCodes],
+ );
+
+ const canAddLabShipment = useMemo(
+ () => detailsAvailableForNewShipment(details, labCases, labDependentCodes).length > 0,
+ [details, labCases, labDependentCodes],
+ );
+
const activeLabCase =
labCases.find((lc) => lc.clientId === activeLabCaseId) ?? labCases[0] ?? null;
const sent = Boolean(activeLabCase?.sentAt);
- function detailSummary(d: TreatmentDetailDraft, idx: number) {
+ const activeLabOrgName = activeLabCase?.destinationOrganizationId
+ ? orgs.find((o) => o.id === activeLabCase.destinationOrganizationId)?.name
+ : null;
+
+ function detailNumber(d: TreatmentDetailDraft) {
+ const idx = details.findIndex((row) => row.clientId === d.clientId);
+ return idx >= 0 ? idx + 1 : 0;
+ }
+
+ function detailSummary(d: TreatmentDetailDraft) {
const typeKey = treatmentTypeLabelKey(d.treatmentType);
const typeLabel =
d.treatmentType in TREATMENT_TYPE_KEYS
? t(typeKey as 'typeEndo')
: d.treatmentType;
const teeth = d.teeth.length ? d.teeth.join(', ') : t('teethNone');
- return `${t('detailLabel', { n: idx + 1 })} · ${typeLabel} · ${teeth}`;
+ return `${t('detailLabel', { n: detailNumber(d) })} · ${typeLabel} · ${teeth}`;
}
function updateActiveLabCase(patch: Partial) {
@@ -105,16 +183,6 @@ export function LabCasesDispatchPanel({
);
}
- function detailAssignedElsewhere(detailClientId: string): boolean {
- if (!activeLabCase) return false;
- return labCases.some(
- (lc) =>
- !lc.sentAt &&
- lc.clientId !== activeLabCase.clientId &&
- lc.detailClientIds.includes(detailClientId),
- );
- }
-
if (labEligibleDetails.length === 0) {
return (
@@ -124,6 +192,15 @@ export function LabCasesDispatchPanel({
);
}
+ const includedInActiveShipment = activeLabCase
+ ? labEligibleDetails.filter((d) => activeLabCase.detailClientIds.includes(d.clientId))
+ : [];
+
+ const pickableForActiveDraft =
+ activeLabCase && !sent
+ ? selectableDetailsForDraftShipment(details, labCases, labDependentCodes, activeLabCase)
+ : [];
+
return (
@@ -133,14 +210,16 @@ export function LabCasesDispatchPanel({
{t('labDispatchSubtitle')} {t('labDispatchSendHint')}
-
- {t('addLabShipment')}
-
+ {canAddLabShipment && (
+
+ {t('addLabShipment')}
+
+ )}
{labCases.length === 0 ? (
@@ -182,105 +261,44 @@ export function LabCasesDispatchPanel({
{activeLabCase && (
-
-
{t('includeDetails')}
-
- {labEligibleDetails.map((d, idx) => {
- const assignedElsewhere = detailAssignedElsewhere(d.clientId);
- const inSentCase = labCases.some(
- (lc) => lc.sentAt && lc.detailClientIds.includes(d.clientId),
- );
- const checked = activeLabCase.detailClientIds.includes(d.clientId);
- const itemDisabled =
- disabled || sent || inSentCase || assignedElsewhere;
-
- return (
- toggleDetailInActiveLabCase(d.clientId, next)}
- label={detailSummary(d, idx)}
- />
- );
- })}
-
-
-
-
- {t('labComment')}
-
-
-
-
{t('selectLab')}
-
- {recentOrganizations.length > 0 && (
-
-
{t('recent')}
- {recentOrganizations.map((o) => (
-
onRecentOrganizationPick(o.id)}
- className="text-xs rounded-[var(--radius-sm)] border border-border/70 px-2 py-1 text-text-secondary hover:text-text-primary hover:border-border focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/35 disabled:opacity-50"
- >
- {o.name}
-
- ))}
+ {sent ? (
+ <>
+
+
+ {t('labShipmentIncludedDetails')}
+
+ {includedInActiveShipment.length === 0 ? (
+
{t('labShipmentNoIncludedDetails')}
+ ) : (
+
+ {includedInActiveShipment.map((d) => (
+
+ {detailSummary(d)}
+
+ ))}
+
+ )}
- )}
-
- updateActiveLabCase({
- destinationOrganizationId: e.target.value || null,
- })
- }
- disabled={disabled || sent || filteredOrganizations.length === 0}
- >
- {t('selectLabPlaceholder')}
- {filteredOrganizations.map((o) => (
-
- {o.name}
-
- ))}
-
- {filteredOrganizations.length === 0 && (
-
{t('noOrgMatch')}
- )}
-
-
-
onSendLabCase(activeLabCase)}
- >
- {t('sendToLab')}
-
- {sent && (
+ {activeLabCase.labComment.trim() ? (
+
+
{t('labComment')}
+
+ {activeLabCase.labComment}
+
+
+ ) : null}
+
+ {activeLabOrgName ? (
+
+
{t('selectLab')}
+
{activeLabOrgName}
+
+ ) : null}
+
- )}
-
+ >
+ ) : (
+ <>
+
+
+ {t('includeDetails')}
+
+ {pickableForActiveDraft.length === 0 ? (
+
{t('labShipmentNoDetailsAvailable')}
+ ) : (
+
+ {pickableForActiveDraft.map((d) => {
+ const checked = activeLabCase.detailClientIds.includes(d.clientId);
+ return (
+ toggleDetailInActiveLabCase(d.clientId, next)}
+ label={detailSummary(d)}
+ />
+ );
+ })}
+
+ )}
+
+
+
+ {t('labComment')}
+
+
+
+
{t('selectLab')}
+
+ {recentOrganizations.length > 0 && (
+
+ {t('recent')}
+ {recentOrganizations.map((o) => (
+ onRecentOrganizationPick(o.id)}
+ className="text-xs rounded-[var(--radius-sm)] border border-border/70 px-2 py-1 text-text-secondary hover:text-text-primary hover:border-border focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/35 disabled:opacity-50"
+ >
+ {o.name}
+
+ ))}
+
+ )}
+
+ updateActiveLabCase({
+ destinationOrganizationId: e.target.value || null,
+ })
+ }
+ disabled={disabled || filteredOrganizations.length === 0}
+ >
+ {t('selectLabPlaceholder')}
+ {filteredOrganizations.map((o) => (
+
+ {o.name}
+
+ ))}
+
+ {filteredOrganizations.length === 0 && (
+
{t('noOrgMatch')}
+ )}
+
+
+
+ onSendLabCase(activeLabCase)}
+ >
+ {t('sendToLab')}
+
+
+ >
+ )}
)}
-
>
)}
--
2.53.0.windows.1
From feb0b26ad00012bbcaef83c1f5991b9bf314b496 Mon Sep 17 00:00:00 2001
From: Admin
Date: Sun, 28 Jun 2026 21:45:33 +0330
Subject: [PATCH 09/17] improvement: treatment preview and treatment history
components overhauled. draft & completed status for treatment plan completely
removed from the flow.
---
.../migration.sql | 8 +
backend/prisma/schema.prisma | 11 +-
.../src/modules/treatments/treatment.utils.ts | 6 -
.../treatments/treatments.controller.ts | 2 +-
.../modules/treatments/treatments.service.ts | 12 +-
frontend/messages/en.json | 37 +--
frontend/messages/fa.json | 37 +--
frontend/messages/nl.json | 37 +--
.../ui/treatment/DetailLabSendBadge.tsx | 43 +++
.../ui/treatment/PastTreatmentsPanel.tsx | 153 ++++------
.../treatment/TreatmentDetailSummaryRow.tsx | 57 ++++
.../ui/treatment/TreatmentDetailsEditor.tsx | 34 +--
.../treatment/TreatmentHistoryDetailLine.tsx | 32 ++
.../TreatmentLatestAttachmentPreview.tsx | 106 -------
.../ui/treatment/TreatmentPreviewCard.tsx | 86 +++---
.../ui/treatment/TreatmentPreviewDialog.tsx | 180 -----------
.../ui/treatment/TreatmentTypeBadge.tsx | 25 ++
.../ui/treatment/TreatmentWorkspace.tsx | 286 ++++++++++++++----
.../ui/treatment/treatmentStatusStyles.ts | 26 ++
frontend/src/types/treatment.ts | 1 -
20 files changed, 571 insertions(+), 608 deletions(-)
create mode 100644 backend/prisma/migrations/20260628170000_remove_treatment_status/migration.sql
create mode 100644 frontend/src/components/ui/treatment/DetailLabSendBadge.tsx
create mode 100644 frontend/src/components/ui/treatment/TreatmentDetailSummaryRow.tsx
create mode 100644 frontend/src/components/ui/treatment/TreatmentHistoryDetailLine.tsx
delete mode 100644 frontend/src/components/ui/treatment/TreatmentLatestAttachmentPreview.tsx
delete mode 100644 frontend/src/components/ui/treatment/TreatmentPreviewDialog.tsx
create mode 100644 frontend/src/components/ui/treatment/TreatmentTypeBadge.tsx
create mode 100644 frontend/src/components/ui/treatment/treatmentStatusStyles.ts
diff --git a/backend/prisma/migrations/20260628170000_remove_treatment_status/migration.sql b/backend/prisma/migrations/20260628170000_remove_treatment_status/migration.sql
new file mode 100644
index 0000000..512b217
--- /dev/null
+++ b/backend/prisma/migrations/20260628170000_remove_treatment_status/migration.sql
@@ -0,0 +1,8 @@
+-- DropIndex
+DROP INDEX IF EXISTS "treatments_organizationId_status_idx";
+
+-- AlterTable
+ALTER TABLE "treatments" DROP COLUMN "status";
+
+-- DropEnum
+DROP TYPE "TreatmentStatus";
diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma
index 41f23ec..b81fde5 100644
--- a/backend/prisma/schema.prisma
+++ b/backend/prisma/schema.prisma
@@ -114,11 +114,6 @@ model Appointment {
@@map("appointments")
}
-enum TreatmentStatus {
- DRAFT
- COMPLETED
-}
-
enum LabTaskStatus {
PENDING
IN_PROGRESS
@@ -126,13 +121,12 @@ enum LabTaskStatus {
}
model Treatment {
- id String @id @default(uuid())
+ id String @id @default(uuid())
organizationId String
patientId String
- appointmentId String? @unique
+ appointmentId String? @unique
providerUserId String
title String
- status TreatmentStatus @default(DRAFT)
treatmentAt DateTime
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
@@ -145,7 +139,6 @@ model Treatment {
updatedAt DateTime @updatedAt
@@index([patientId, treatmentAt])
- @@index([organizationId, status])
@@map("treatments")
}
diff --git a/backend/src/modules/treatments/treatment.utils.ts b/backend/src/modules/treatments/treatment.utils.ts
index 6943064..f2bfdfa 100644
--- a/backend/src/modules/treatments/treatment.utils.ts
+++ b/backend/src/modules/treatments/treatment.utils.ts
@@ -1,5 +1,3 @@
-import { TreatmentStatus } from '@prisma/client';
-
const FDI_TOOTH_IDS = new Set([
'11', '12', '13', '14', '15', '16', '17', '18',
'21', '22', '23', '24', '25', '26', '27', '28',
@@ -39,7 +37,3 @@ export function generateTreatmentTitle(
return parts.join(' · ');
}
-
-export function mapTreatmentStatusForApi(status: TreatmentStatus): string {
- return status === TreatmentStatus.DRAFT ? 'draft' : 'completed';
-}
diff --git a/backend/src/modules/treatments/treatments.controller.ts b/backend/src/modules/treatments/treatments.controller.ts
index 5f91bba..8a9eb85 100644
--- a/backend/src/modules/treatments/treatments.controller.ts
+++ b/backend/src/modules/treatments/treatments.controller.ts
@@ -40,7 +40,7 @@ export class TreatmentsController {
}
@Get('patients/:patientId/history')
- @ApiOperation({ summary: 'List completed treatments for a patient (TAB_TREATMENT_READ)' })
+ @ApiOperation({ summary: 'List treatments for a patient (draft and completed, TAB_TREATMENT_READ)' })
listPatientHistory(
@Param('patientId') patientId: string,
@Query('limit', new ParseIntPipe({ optional: true })) limit = 20,
diff --git a/backend/src/modules/treatments/treatments.service.ts b/backend/src/modules/treatments/treatments.service.ts
index 99b7846..51e96a7 100644
--- a/backend/src/modules/treatments/treatments.service.ts
+++ b/backend/src/modules/treatments/treatments.service.ts
@@ -4,7 +4,7 @@ import {
Injectable,
NotFoundException,
} from '@nestjs/common';
-import { LinkStatus, TreatmentStatus } from '@prisma/client';
+import { LinkStatus } from '@prisma/client';
import { createReadStream, existsSync, mkdirSync } from 'fs';
import { join } from 'path';
import { randomUUID } from 'crypto';
@@ -17,7 +17,6 @@ import {
} from './dto/treatment.dto';
import {
generateTreatmentTitle,
- mapTreatmentStatusForApi,
normalizeTeeth,
} from './treatment.utils';
@@ -117,7 +116,7 @@ export class TreatmentsService {
where: {
patientId,
organizationId,
- status: TreatmentStatus.COMPLETED,
+ details: { some: {} },
},
include: treatmentInclude,
orderBy: [{ treatmentAt: 'desc' }],
@@ -144,7 +143,6 @@ export class TreatmentsService {
where: {
appointmentId: appointment.id,
organizationId,
- status: TreatmentStatus.DRAFT,
},
include: treatmentInclude,
});
@@ -196,7 +194,6 @@ export class TreatmentsService {
treatmentAt: appointment.startAt,
patientId: appointment.patientId,
providerUserId: appointment.providerUserId,
- status: TreatmentStatus.DRAFT,
},
})
: await tx.treatment.create({
@@ -206,7 +203,6 @@ export class TreatmentsService {
appointmentId: appointment.id,
providerUserId: appointment.providerUserId,
title,
- status: TreatmentStatus.DRAFT,
treatmentAt: appointment.startAt,
},
});
@@ -314,7 +310,7 @@ export class TreatmentsService {
);
const treatment = await this.prisma.treatment.findFirst({
- where: { appointmentId: appointment.id, organizationId, status: TreatmentStatus.DRAFT },
+ where: { appointmentId: appointment.id, organizationId },
select: { id: true },
});
@@ -605,7 +601,6 @@ export class TreatmentsService {
patientId: string;
appointmentId: string | null;
title: string;
- status: TreatmentStatus;
treatmentAt: Date;
details: Array<{
id: string;
@@ -660,7 +655,6 @@ export class TreatmentsService {
appointmentId: treatment.appointmentId,
title: treatment.title,
treatmentAt: treatment.treatmentAt.toISOString(),
- status: mapTreatmentStatusForApi(treatment.status),
details: treatment.details.map((d) => this.mapDetail(d)),
labCases: treatment.labCases.map((lc) => this.mapLabCase(lc)),
documents,
diff --git a/frontend/messages/en.json b/frontend/messages/en.json
index 5836698..138c125 100644
--- a/frontend/messages/en.json
+++ b/frontend/messages/en.json
@@ -416,7 +416,6 @@
"loadingAppointments": "Loading appointments…",
"selectDayWithAppointment": "Select a day with at least one appointment.",
"confirmDiscard": "You have unsaved changes. Discard them and continue?",
- "successDraftSaved": "Treatment draft saved.",
"errorChooseOrg": "Choose at least one active organization to send this case.",
"successCaseSent": "Case sent to selected organizations.",
"successFilesUploaded": "{count} file(s) uploaded successfully.",
@@ -428,7 +427,7 @@
"errorSaveDraft": "Failed to save treatment draft.",
"errorSendCase": "Failed to send case.",
"errorCaseMustSave": "Case must be saved before sending.",
- "draftTitle": "Draft · {patientName}",
+ "treatmentPlanTitle": "Treatment · {patientName}",
"hiddenMessage": "Appointments are hidden.",
"showAppointments": "Show appointments",
"appointmentsTitle": "My appointments",
@@ -483,36 +482,30 @@
"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",
"saveStatusSaving": "Saving…",
"saveStatusSaved": "All changes saved",
"saveStatusError": "Could not save — check your connection",
"sendSavesFirst": "Sending is per case and saves first automatically.",
"historyTitle": "Previous treatments",
- "historySubtitle": "Completed treatments for this patient. Each case is listed separately.",
+ "historySubtitle": "Click a treatment to preview it. Use Open in the preview card to load it in the workspace.",
"loadingHistory": "Loading history…",
- "historyEmpty": "No prior treatments for this patient.",
- "statusLabel": "Status:",
- "historyCaseLabel": "Case {n} · {type}",
+ "historyEmpty": "No other treatments recorded for this patient yet.",
+ "historyDetailLabel": "Detail {n} · {type}",
+ "previewTitle": "Treatment preview",
+ "openTreatment": "Open",
+ "selectAppointment": "Select an appointment to preview its treatment.",
+ "detailCount": "{n} detail(s)",
+ "detailSummary": "Detail {n}: {type}",
+ "detailAttachmentCount": "{n, plural, one {# file} other {# files}}",
+ "detailNotSentToLab": "Not sent to lab",
+ "detailPendingLabSend": "This lab detail has not been sent yet.",
"teethLabel": "Teeth:",
"teethNone": "None selected",
- "reviewDetails": "Review details",
- "previewTitle": "Treatment preview",
- "previewDraft": "Preview current draft",
- "selectAppointment": "Select an appointment to preview its draft.",
- "caseCount": "{n} case(s)",
- "attachmentCount": "{n} attachment(s)",
- "caseSummary": "Case {n}: {type}",
- "teethPrefix": "· Teeth",
- "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.",
+ "historicalReadonlyNotice": "You are viewing a past treatment (read-only).",
+ "errorNoAppointmentForTreatment": "This treatment has no linked appointment and cannot be opened.",
"noCases": "No cases in this treatment.",
- "noDetails": "No treatment details in this draft.",
+ "noDetails": "No treatment details yet.",
"typeLabel": "Type:",
"commentsLabel": "Comments:",
"commentsEmpty": "Comments: —",
diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json
index c5560b2..d2a4b92 100644
--- a/frontend/messages/fa.json
+++ b/frontend/messages/fa.json
@@ -416,7 +416,6 @@
"loadingAppointments": "در حال بارگذاری نوبتها...",
"selectDayWithAppointment": "روزی را انتخاب کنید که حداقل یک نوبت داشته باشد.",
"confirmDiscard": "تغییرات ذخیرهنشده دارید. آنها را کنار بگذارید و ادامه دهید؟",
- "successDraftSaved": "پیشنویس درمان ذخیره شد.",
"errorChooseOrg": "حداقل یک سازمان فعال را برای ارسال این پرونده انتخاب کنید.",
"successCaseSent": "پرونده به سازمانهای انتخاب شده ارسال شد.",
"successFilesUploaded": "{count} فایل با موفقیت بارگذاری شد.",
@@ -428,7 +427,7 @@
"errorSaveDraft": "ذخیره پیشنویس درمان ناموفق بود.",
"errorSendCase": "ارسال پرونده ناموفق بود.",
"errorCaseMustSave": "پرونده باید قبل از ارسال ذخیره شود.",
- "draftTitle": "پیشنویس · {patientName}",
+ "treatmentPlanTitle": "درمان · {patientName}",
"hiddenMessage": "نوبتها پنهان هستند.",
"showAppointments": "نمایش نوبتها",
"appointmentsTitle": "نوبتهای من",
@@ -483,35 +482,29 @@
"successLabShipmentsSaved": "محمولههای لاب ذخیره شد.",
"errorSaveLabShipments": "ذخیره محمولههای لاب ناموفق بود.",
"errorLabCaseNeedsDetails": "حداقل یک جزئیات درمان برای این محموله انتخاب کنید.",
- "saveDraft": "ذخیره پیشنویس درمان",
"unsavedChanges": "تغییرات ذخیرهنشده",
- "draftSaved": "پیشنویس ذخیره شد",
"saveStatusSaving": "در حال ذخیره…",
"saveStatusSaved": "همه تغییرات ذخیره شد",
"saveStatusError": "ذخیره ناموفق بود — اتصال را بررسی کنید",
"sendSavesFirst": "ارسال برای هر پرونده به صورت جداگانه است و ابتدا به طور خودکار ذخیره میکند.",
"historyTitle": "درمانهای قبلی",
- "historySubtitle": "درمانهای تکمیل شده برای این بیمار. هر پرونده به طور جداگانه فهرست شده است.",
+ "historySubtitle": "برای پیشنمایش روی یک درمان کلیک کنید. از دکمه باز کردن در کارت پیشنمایش برای بارگذاری در فضای کاری استفاده کنید.",
"loadingHistory": "در حال بارگذاری تاریخچه...",
- "historyEmpty": "هیچ درمان قبلی برای این بیمار وجود ندارد.",
- "statusLabel": "وضعیت:",
- "historyCaseLabel": "پرونده {n} · {type}",
+ "historyEmpty": "هیچ درمان دیگری برای این بیمار ثبت نشده است.",
+ "historyDetailLabel": "جزئیات {n} · {type}",
+ "previewTitle": "پیشنمایش درمان",
+ "openTreatment": "باز کردن",
+ "selectAppointment": "یک نوبت را برای پیشنمایش درمان انتخاب کنید.",
+ "detailCount": "{n} جزئیات",
+ "detailSummary": "جزئیات {n}: {type}",
+ "detailAttachmentCount": "{n} فایل",
+ "detailNotSentToLab": "به لاب ارسال نشده",
+ "detailPendingLabSend": "این جزئیات لاب هنوز ارسال نشده است.",
+ "historicalReadonlyNotice": "در حال مشاهده یک درمان گذشته (فقط خواندنی) هستید.",
+ "errorNoAppointmentForTreatment": "این درمان نوبت مرتبطی ندارد و قابل باز کردن نیست.",
"teethLabel": "دندانها:",
"teethNone": "هیچکدام انتخاب نشده",
- "reviewDetails": "بررسی جزئیات",
- "previewTitle": "پیشنمایش درمان",
- "previewDraft": "پیشنمایش پیشنویس فعلی",
- "selectAppointment": "یک نوبت را برای پیشنمایش پیشنویس آن انتخاب کنید.",
- "caseCount": "{n} پرونده",
- "attachmentCount": "{n} پیوست",
- "caseSummary": "پرونده {n}: {type}",
- "teethPrefix": "· دندانها",
- "moreCases": "+ {n} پرونده دیگر",
- "previewDialogTitle": "پیشنمایش درمان",
- "previewDialogSubtitle": "بررسی پروندهها، پیوستها و مقصدهای ارسال.",
- "previewDialogSubtitlePhase4": "بررسی جزئیات درمان و پیوستها.",
- "previewLabDispatchHint": "برای ارسال کار به لابراتوار از بخش ارسال لاب در فضای کاری استفاده کنید.",
- "noDetails": "جزئیات درمانی در این پیشنویس وجود ندارد.",
+ "noDetails": "هنوز جزئیات درمانی وجود ندارد.",
"noCases": "هیچ پروندهای در این درمان وجود ندارد.",
"typeLabel": "نوع:",
"commentsLabel": "نظرات:",
diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json
index 56a2e0f..714ffb5 100644
--- a/frontend/messages/nl.json
+++ b/frontend/messages/nl.json
@@ -416,7 +416,6 @@
"loadingAppointments": "Afspraken laden...",
"selectDayWithAppointment": "Selecteer een dag met ten minste één afspraak.",
"confirmDiscard": "U heeft niet-opgeslagen wijzigingen. Wilt u deze negeren en doorgaan?",
- "successDraftSaved": "Behandelconcept opgeslagen.",
"errorChooseOrg": "Kies ten minste één actieve organisatie om deze case te verzenden.",
"successCaseSent": "Case verzonden naar geselecteerde organisaties.",
"successFilesUploaded": "{count} bestand(en) succesvol geüpload.",
@@ -428,7 +427,7 @@
"errorSaveDraft": "Behandelconcept opslaan mislukt.",
"errorSendCase": "Case verzenden mislukt.",
"errorCaseMustSave": "Case moet worden opgeslagen voor verzending.",
- "draftTitle": "Concept · {patientName}",
+ "treatmentPlanTitle": "Behandeling · {patientName}",
"hiddenMessage": "Afspraken zijn verborgen.",
"showAppointments": "Afspraken tonen",
"appointmentsTitle": "Mijn afspraken",
@@ -483,36 +482,30 @@
"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",
"saveStatusSaving": "Opslaan…",
"saveStatusSaved": "Alle wijzigingen opgeslagen",
"saveStatusError": "Opslaan mislukt — controleer uw verbinding",
"sendSavesFirst": "Verzenden is per case en slaat eerst automatisch op.",
"historyTitle": "Eerdere behandelingen",
- "historySubtitle": "Voltooide behandelingen voor deze patiënt. Elke case wordt afzonderlijk weergegeven.",
+ "historySubtitle": "Klik op een behandeling om te bekijken. Gebruik Open in de voorbeeldkkaart om deze in de werkruimte te laden.",
"loadingHistory": "Geschiedenis laden...",
- "historyEmpty": "Geen eerdere behandelingen voor deze patiënt.",
- "statusLabel": "Status:",
- "historyCaseLabel": "Case {n} · {type}",
+ "historyEmpty": "Geen andere behandelingen voor deze patiënt geregistreerd.",
+ "historyDetailLabel": "Detail {n} · {type}",
+ "previewTitle": "Behandelvoorbeeld",
+ "openTreatment": "Openen",
+ "selectAppointment": "Selecteer een afspraak om de behandeling te bekijken.",
+ "detailCount": "{n} detail(s)",
+ "detailSummary": "Detail {n}: {type}",
+ "detailAttachmentCount": "{n, plural, one {# bestand} other {# bestanden}}",
+ "detailNotSentToLab": "Niet naar lab verzonden",
+ "detailPendingLabSend": "Dit labdetail is nog niet verzonden.",
+ "historicalReadonlyNotice": "U bekijkt een eerdere behandeling (alleen-lezen).",
+ "errorNoAppointmentForTreatment": "Deze behandeling heeft geen gekoppelde afspraak en kan niet worden geopend.",
"teethLabel": "Tanden:",
"teethNone": "Geen geselecteerd",
- "reviewDetails": "Details bekijken",
- "previewTitle": "Behandelvoorbeeld",
- "previewDraft": "Bekijk huidig concept",
- "selectAppointment": "Selecteer een afspraak om het concept te bekijken.",
- "caseCount": "{n} case(s)",
- "attachmentCount": "{n} bijlage(n)",
- "caseSummary": "Case {n}: {type}",
- "teethPrefix": "· Tanden",
- "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.",
+ "noDetails": "Nog geen behandeldetails.",
"typeLabel": "Type:",
"commentsLabel": "Opmerkingen:",
"commentsEmpty": "Opmerkingen: —",
diff --git a/frontend/src/components/ui/treatment/DetailLabSendBadge.tsx b/frontend/src/components/ui/treatment/DetailLabSendBadge.tsx
new file mode 100644
index 0000000..ddff044
--- /dev/null
+++ b/frontend/src/components/ui/treatment/DetailLabSendBadge.tsx
@@ -0,0 +1,43 @@
+'use client';
+
+import { useTranslations } from 'next-intl';
+import { CaseSentLabel } from '@/components/ui/treatment/CaseSentLabel';
+import { labNotSentBadgeClass, labSentBadgeClass } from '@/components/ui/treatment/treatmentStatusStyles';
+import type { LinkedOrganizationOption, PastTreatmentDetail } from '@/types/treatment';
+
+interface DetailLabSendBadgeProps {
+ detail: Pick<
+ PastTreatmentDetail,
+ 'treatmentType' | 'sentAt' | 'sends' | 'destinationOrganizationId'
+ >;
+ labDependentCodes: Set;
+ orgs?: LinkedOrganizationOption[];
+ className?: string;
+}
+
+export function DetailLabSendBadge({
+ detail,
+ labDependentCodes,
+ orgs,
+ className = '',
+}: DetailLabSendBadgeProps) {
+ const t = useTranslations('treatment');
+
+ if (!labDependentCodes.has(detail.treatmentType)) {
+ return null;
+ }
+
+ if (detail.sentAt) {
+ return (
+
+ );
+ }
+
+ return (
+ {t('detailNotSentToLab')}
+ );
+}
diff --git a/frontend/src/components/ui/treatment/PastTreatmentsPanel.tsx b/frontend/src/components/ui/treatment/PastTreatmentsPanel.tsx
index e46997d..178f55f 100644
--- a/frontend/src/components/ui/treatment/PastTreatmentsPanel.tsx
+++ b/frontend/src/components/ui/treatment/PastTreatmentsPanel.tsx
@@ -1,39 +1,29 @@
'use client';
import { useTranslations } from 'next-intl';
-import { FileText } from 'lucide-react';
+import { TreatmentHistoryDetailLine } from '@/components/ui/treatment/TreatmentHistoryDetailLine';
import type { PastTreatment } from '@/types/treatment';
-import { CaseSentLabel } from '@/components/ui/treatment/CaseSentLabel';
-
-const TREATMENT_TYPE_KEYS = {
- consultation: 'typeConsultation',
- filling: 'typeFilling',
- endo: 'typeEndo',
- visit: 'typeVisit',
- hygiene: 'typeHygiene',
-} as const;
interface PastTreatmentsPanelProps {
items: PastTreatment[];
loading?: boolean;
- onReviewTreatment?: (treatment: PastTreatment) => void;
+ selectedPreviewId?: string | null;
+ onSelectTreatment?: (treatment: PastTreatment) => void;
}
export function PastTreatmentsPanel({
items,
loading,
- onReviewTreatment,
+ selectedPreviewId,
+ onSelectTreatment,
}: PastTreatmentsPanelProps) {
const t = useTranslations('treatment');
- const tCommon = useTranslations('common');
return (
{t('historyTitle')}
-
- {t('historySubtitle')}
-
+
{t('historySubtitle')}
{loading &&
{t('loadingHistory')}
}
@@ -42,95 +32,60 @@ export function PastTreatmentsPanel({
{t('historyEmpty')}
)}
-
- {items.map((treatment) => (
-
-
-
-
{treatment.title}
-
- {t('statusLabel')} {treatment.status}
-
-
+
+ {items.map((treatment) => {
+ const isSelected = selectedPreviewId === treatment.id;
+
+ return (
+
onSelectTreatment?.(treatment)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ onSelectTreatment?.(treatment);
+ }
+ }}
+ className={`
+ border rounded-[var(--radius-sm)] px-2 py-1.5 cursor-pointer transition-colors
+ focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/45
+ ${
+ isSelected
+ ? 'border-primary bg-primary/5'
+ : 'border-border/60 bg-background-secondary/30 hover:border-border hover:bg-background-secondary/50'
+ }
+ `}
+ >
- {new Date(treatment.treatmentAt).toLocaleDateString()}
+ {new Date(treatment.treatmentAt).toLocaleDateString(undefined, {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ })}
-
-
- {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;
- return (
-
-
-
- {t('historyCaseLabel', { n: idx + 1, type: typeLabel })}
-
- {c.sentAt && (
-
- )}
+ {treatment.details.length === 0 ? (
+
{t('noDetails')}
+ ) : (
+
+ {treatment.details.map((detail, idx) => (
+
+
-
- {t('teethLabel')} {c.teeth.length ? [...c.teeth].sort().join(', ') : t('teethNone')}
-
- {c.notes?.trim() && (
-
{c.notes}
- )}
-
-
- {t('attachments')}
-
- {attachments.length === 0 ? (
-
{tCommon('none')}
- ) : (
-
- {attachments.map((doc) => (
-
-
- {doc.fileName}
-
- {(doc.sizeBytes / 1024).toFixed(1)} KB
-
-
- ))}
-
- )}
-
-
- );
- })}
-
-
- {onReviewTreatment && (
-
- onReviewTreatment(treatment)}
- className="text-xs text-primary hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/35 rounded-[var(--radius-sm)] px-1"
- >
- {t('reviewDetails')}
-
-
- )}
-
- ))}
+ ))}
+
+ )}
+
+ );
+ })}
);
diff --git a/frontend/src/components/ui/treatment/TreatmentDetailSummaryRow.tsx b/frontend/src/components/ui/treatment/TreatmentDetailSummaryRow.tsx
new file mode 100644
index 0000000..ea16c8a
--- /dev/null
+++ b/frontend/src/components/ui/treatment/TreatmentDetailSummaryRow.tsx
@@ -0,0 +1,57 @@
+'use client';
+
+import { useTranslations } from 'next-intl';
+import { DetailLabSendBadge } from '@/components/ui/treatment/DetailLabSendBadge';
+import { TreatmentTypeBadge } from '@/components/ui/treatment/TreatmentTypeBadge';
+import type { LinkedOrganizationOption, PastTreatmentDetail } from '@/types/treatment';
+
+interface TreatmentDetailSummaryRowProps {
+ detail: PastTreatmentDetail;
+ detailNumber: number;
+ labDependentCodes: Set;
+ orgs?: LinkedOrganizationOption[];
+ compact?: boolean;
+}
+
+export function TreatmentDetailSummaryRow({
+ detail,
+ detailNumber,
+ labDependentCodes,
+ orgs,
+ compact = false,
+}: TreatmentDetailSummaryRowProps) {
+ const t = useTranslations('treatment');
+ const teeth = detail.teeth.length ? [...detail.teeth].sort().join(', ') : t('teethNone');
+ const attachmentCount = detail.attachmentMetas?.length ?? 0;
+
+ return (
+
+
+
+
+ {t('detailLabel', { n: detailNumber })}
+
+
+
+
+
+
+ {t('teethLabel')} {teeth}
+
+ {attachmentCount > 0 && (
+
+ {t('detailAttachmentCount', { n: attachmentCount })}
+
+ )}
+ {detail.notes?.trim() && (
+
+ {detail.notes}
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx b/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx
index b3693fb..ab4b388 100644
--- a/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx
+++ b/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx
@@ -4,6 +4,11 @@ import { useRef } from 'react';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/shared/Button';
import { Dropdown } from '@/components/ui/shared/Dropdown';
+import {
+ autosaveStatusClass,
+ labPendingBannerClass,
+ labSentBannerClass,
+} from '@/components/ui/treatment/treatmentStatusStyles';
import type { TreatmentDetailDraft } from '@/types/treatment';
import { TREATMENT_TYPE_COLORS, treatmentTypeLabelKey } from '@/components/ui/treatment/treatmentTypeDisplay';
@@ -13,12 +18,12 @@ interface TreatmentDetailsEditorProps {
onActiveDetailChange: (id: string) => void;
onDetailsChange: (details: TreatmentDetailDraft[]) => void;
isDetailLocked: (detail: TreatmentDetailDraft) => boolean;
+ labDependentCodes: Set;
disabled: boolean;
canEdit: boolean;
saveStatus: 'idle' | 'dirty' | 'saving' | 'saved' | 'error';
uploadBusy: boolean;
onAddDetail: () => void;
- onPreview: () => void;
onUploadFiles: (files: FileList | null) => void;
}
@@ -28,16 +33,15 @@ export function TreatmentDetailsEditor({
onActiveDetailChange,
onDetailsChange,
isDetailLocked,
+ labDependentCodes,
disabled,
canEdit,
saveStatus,
uploadBusy,
onAddDetail,
- onPreview,
onUploadFiles,
}: TreatmentDetailsEditorProps) {
const t = useTranslations('treatment');
- const tCommon = useTranslations('common');
const attachmentInputRef = useRef(null);
const activeDetail = details.find((d) => d.clientId === activeDetailId) ?? details[0];
@@ -46,6 +50,8 @@ export function TreatmentDetailsEditor({
const locked = isDetailLocked(activeDetail);
const readOnly = disabled || locked;
const treatmentTypeTextColor = TREATMENT_TYPE_COLORS[activeDetail.treatmentType];
+ const isLabDependent = labDependentCodes.has(activeDetail.treatmentType);
+ const showPendingLabHint = isLabDependent && !locked && !readOnly;
return (
@@ -54,14 +60,9 @@ export function TreatmentDetailsEditor({
{t('detailsTitle')}
{t('detailsSubtitle')}
-
-
- {tCommon('preview')}
-
-
- {t('addDetail')}
-
-
+
+ {t('addDetail')}
+
@@ -88,9 +89,10 @@ export function TreatmentDetailsEditor({
{locked && (
-
- {t('detailLockedInShipment')}
-
+
{t('detailLockedInShipment')}
+ )}
+ {showPendingLabHint && (
+
{t('detailPendingLabSend')}
)}
@@ -173,9 +175,7 @@ export function TreatmentDetailsEditor({
{canEdit && saveStatus !== 'idle' && (
diff --git a/frontend/src/components/ui/treatment/TreatmentHistoryDetailLine.tsx b/frontend/src/components/ui/treatment/TreatmentHistoryDetailLine.tsx
new file mode 100644
index 0000000..d549190
--- /dev/null
+++ b/frontend/src/components/ui/treatment/TreatmentHistoryDetailLine.tsx
@@ -0,0 +1,32 @@
+'use client';
+
+import { useTranslations } from 'next-intl';
+import { TreatmentTypeBadge } from '@/components/ui/treatment/TreatmentTypeBadge';
+import type { PastTreatmentDetail } from '@/types/treatment';
+
+interface TreatmentHistoryDetailLineProps {
+ detail: PastTreatmentDetail;
+ detailNumber: number;
+}
+
+export function TreatmentHistoryDetailLine({
+ detail,
+ detailNumber,
+}: TreatmentHistoryDetailLineProps) {
+ const t = useTranslations('treatment');
+ const teeth = detail.teeth.length ? [...detail.teeth].sort().join(', ') : t('teethNone');
+ const attachmentCount = detail.attachmentMetas?.length ?? 0;
+
+ return (
+
+ {detailNumber}.
+
+ {teeth}
+ {attachmentCount > 0 && (
+
+ {t('detailAttachmentCount', { n: attachmentCount })}
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/ui/treatment/TreatmentLatestAttachmentPreview.tsx b/frontend/src/components/ui/treatment/TreatmentLatestAttachmentPreview.tsx
deleted file mode 100644
index 64dcbb9..0000000
--- a/frontend/src/components/ui/treatment/TreatmentLatestAttachmentPreview.tsx
+++ /dev/null
@@ -1,106 +0,0 @@
-'use client';
-
-import { useEffect, useState } from 'react';
-import { useTranslations } from 'next-intl';
-import { FileText } from 'lucide-react';
-import { treatmentsApi } from '@/lib/api/treatments';
-import type { TreatmentAttachmentMeta } from '@/types/treatment';
-
-interface TreatmentLatestAttachmentPreviewProps {
- attachment?: TreatmentAttachmentMeta | null;
- className?: string;
-}
-
-function isImageMime(mimeType: string): boolean {
- return mimeType.startsWith('image/');
-}
-
-function isPdfMime(mimeType: string): boolean {
- return mimeType === 'application/pdf';
-}
-
-export function TreatmentLatestAttachmentPreview({
- attachment,
- className = '',
-}: TreatmentLatestAttachmentPreviewProps) {
- const tCommon = useTranslations('common');
- const [previewUrl, setPreviewUrl] = useState(null);
- const [loadFailed, setLoadFailed] = useState(false);
- const [loading, setLoading] = useState(false);
-
- const canRenderPreview = attachment
- ? isImageMime(attachment.mimeType) || isPdfMime(attachment.mimeType)
- : false;
-
- useEffect(() => {
- if (!attachment || !canRenderPreview) {
- setPreviewUrl(null);
- setLoadFailed(false);
- setLoading(false);
- return;
- }
-
- let cancelled = false;
- let objectUrl: string | null = null;
-
- setLoading(true);
- setLoadFailed(false);
- setPreviewUrl(null);
-
- void treatmentsApi
- .getAttachmentFileBlob(attachment.id)
- .then((blob) => {
- if (cancelled) return;
- objectUrl = URL.createObjectURL(blob);
- setPreviewUrl(objectUrl);
- })
- .catch(() => {
- if (!cancelled) setLoadFailed(true);
- })
- .finally(() => {
- if (!cancelled) setLoading(false);
- });
-
- return () => {
- cancelled = true;
- if (objectUrl) URL.revokeObjectURL(objectUrl);
- };
- }, [attachment, canRenderPreview]);
-
- return (
-
- {!attachment ? (
-
- {tCommon('none')}
-
- ) : loading ? (
-
- {tCommon('loadingEllipsis')}
-
- ) : loadFailed || !canRenderPreview || !previewUrl ? (
-
-
-
- {attachment.fileName}
-
-
- ) : isImageMime(attachment.mimeType) ? (
- // eslint-disable-next-line @next/next/no-img-element
-
- ) : (
-
- )}
-
- );
-}
diff --git a/frontend/src/components/ui/treatment/TreatmentPreviewCard.tsx b/frontend/src/components/ui/treatment/TreatmentPreviewCard.tsx
index 9f8f425..4349be3 100644
--- a/frontend/src/components/ui/treatment/TreatmentPreviewCard.tsx
+++ b/frontend/src/components/ui/treatment/TreatmentPreviewCard.tsx
@@ -2,71 +2,61 @@
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/shared/Button';
-import type { PastTreatment } from '@/types/treatment';
-
-const TREATMENT_TYPE_KEYS = {
- consultation: 'typeConsultation',
- filling: 'typeFilling',
- endo: 'typeEndo',
- visit: 'typeVisit',
- hygiene: 'typeHygiene',
-} as const;
+import { TreatmentDetailSummaryRow } from '@/components/ui/treatment/TreatmentDetailSummaryRow';
+import type { LinkedOrganizationOption, PastTreatment } from '@/types/treatment';
interface TreatmentPreviewCardProps {
- draft: PastTreatment | null;
- disabled?: boolean;
- onPreview: () => void;
+ treatment: PastTreatment | null;
+ labDependentCodes: Set;
+ orgs?: LinkedOrganizationOption[];
+ openDisabled?: boolean;
+ onOpen: () => void;
}
-export function TreatmentPreviewCard({ draft, disabled, onPreview }: TreatmentPreviewCardProps) {
+export function TreatmentPreviewCard({
+ treatment,
+ labDependentCodes,
+ orgs,
+ openDisabled = false,
+ onOpen,
+}: TreatmentPreviewCardProps) {
const t = useTranslations('treatment');
- const attachmentCount = draft
- ? draft.details.reduce((n, c) => n + (c.attachmentMetas?.length ?? 0), 0)
- : 0;
-
return (
{t('previewTitle')}
-
- {t('previewDraft')}
+
+ {t('openTreatment')}
- {!draft ? (
+ {!treatment ? (
{t('selectAppointment')}
) : (
-
{draft.title}
-
{draft.status}
+
{treatment.title}
+
+ {new Date(treatment.treatmentAt).toLocaleDateString()}
+
-
- {t('caseCount', { n: draft.details.length })} ·{' '}
- {t('attachmentCount', { n: attachmentCount })}
-
-
- {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 (
-
-
- {t('caseSummary', { n: idx + 1, type: typeLabel })}
-
- {c.teeth.length > 0 && (
-
- {t('teethPrefix')} {[...c.teeth].sort().join(', ')}
-
- )}
-
- );
- })}
- {draft.details.length > 2 && (
-
{t('moreCases', { n: draft.details.length - 2 })}
+
+ {treatment.details.length === 0 ? (
+
{t('noDetails')}
+ ) : (
+ treatment.details.map((detail, idx) => (
+
+ ))
)}
diff --git a/frontend/src/components/ui/treatment/TreatmentPreviewDialog.tsx b/frontend/src/components/ui/treatment/TreatmentPreviewDialog.tsx
deleted file mode 100644
index ccb9198..0000000
--- a/frontend/src/components/ui/treatment/TreatmentPreviewDialog.tsx
+++ /dev/null
@@ -1,180 +0,0 @@
-'use client';
-
-import { useRef } from 'react';
-import { useTranslations } from 'next-intl';
-import { Loader2, Paperclip } from 'lucide-react';
-import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
-import type { LinkedOrganizationOption, PastTreatment, PastTreatmentCase } from '@/types/treatment';
-import { CaseSentLabel } from '@/components/ui/treatment/CaseSentLabel';
-import { TreatmentLatestAttachmentPreview } from '@/components/ui/treatment/TreatmentLatestAttachmentPreview';
-import { treatmentTypeLabelKey } from '@/components/ui/treatment/treatmentTypeDisplay';
-
-export type TreatmentPreviewMode = 'readonly' | 'editable';
-
-interface TreatmentPreviewDialogProps {
- open: boolean;
- onClose: () => void;
- treatment: PastTreatment | null;
- mode: TreatmentPreviewMode;
- orgs?: LinkedOrganizationOption[];
- uploadBusyCaseId?: string | null;
- onAttach?: (caseKey: string, files: FileList) => void | Promise
;
-}
-
-function caseKey(c: PastTreatmentCase): string {
- return c.clientId ?? c.id;
-}
-
-const caseActionIconClass =
- 'inline-flex items-center justify-center rounded-[var(--radius-sm)] p-1.5 text-text-secondary transition-colors hover:bg-background-card/80 hover:text-text-primary disabled:cursor-not-allowed disabled:opacity-40';
-
-export function TreatmentPreviewDialog({
- open,
- onClose,
- treatment,
- mode,
- orgs = [],
- uploadBusyCaseId,
- onAttach,
-}: TreatmentPreviewDialogProps) {
- const t = useTranslations('treatment');
- const fileInputsRef = useRef>({});
-
- if (!open || !treatment) return null;
-
- const editable = mode === 'editable';
-
- return (
-
-
-
-
-
- {t('previewDialogTitle')}
-
-
{t('previewDialogSubtitlePhase4')}
-
-
-
-
-
-
-
{treatment.title}
-
- {new Date(treatment.treatmentAt).toLocaleDateString()}
-
-
-
- {t('statusLabel')} {treatment.status}
-
-
- {editable && (
-
{t('previewLabDispatchHint')}
- )}
-
- {treatment.details.length === 0 ? (
-
{t('noDetails')}
- ) : (
-
- {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 comment = c.notes?.trim() ?? '';
- const attachBusy = uploadBusyCaseId === key;
- const typeKey = treatmentTypeLabelKey(c.treatmentType);
- const typeLabel = t(typeKey as 'typeConsultation');
-
- return (
-
-
-
-
-
- {t('detailLabel', { n: idx + 1 })}
-
- {actionsEnabled && onAttach && (
-
-
{
- fileInputsRef.current[key] = el;
- }}
- type="file"
- multiple
- className="sr-only"
- aria-hidden
- onChange={(e) => {
- if (e.target.files?.length) {
- void onAttach(key, e.target.files);
- }
- e.target.value = '';
- }}
- />
-
fileInputsRef.current[key]?.click()}
- >
- {attachBusy ? (
-
- ) : (
-
- )}
-
-
- )}
-
-
- {t('typeLabel')} {typeLabel}
-
-
- {t('teethLabel')}{' '}
- {c.teeth.length ? [...c.teeth].sort().join(', ') : t('teethNone')}
-
- {comment ? (
-
- {t('commentsLabel')} {comment}
-
- ) : (
-
{t('commentsEmpty')}
- )}
-
-
-
- {sent && (
-
- )}
-
- {t('attachments')}
-
-
-
-
-
- );
- })}
-
- )}
-
-
-
- );
-}
diff --git a/frontend/src/components/ui/treatment/TreatmentTypeBadge.tsx b/frontend/src/components/ui/treatment/TreatmentTypeBadge.tsx
new file mode 100644
index 0000000..9c01bb6
--- /dev/null
+++ b/frontend/src/components/ui/treatment/TreatmentTypeBadge.tsx
@@ -0,0 +1,25 @@
+'use client';
+
+import { useTranslations } from 'next-intl';
+import { purposeStyle } from '@/components/ui/appointments/appointmentPurposeStyles';
+import { TREATMENT_TYPE_KEYS, treatmentTypeLabelKey } from '@/components/ui/treatment/treatmentTypeDisplay';
+
+interface TreatmentTypeBadgeProps {
+ type: string;
+ className?: string;
+}
+
+export function TreatmentTypeBadge({ type, className = '' }: TreatmentTypeBadgeProps) {
+ const t = useTranslations('treatment');
+ const typeKey = treatmentTypeLabelKey(type);
+ const label =
+ type in TREATMENT_TYPE_KEYS ? t(typeKey as 'typeEndo') : type;
+
+ return (
+
+ {label}
+
+ );
+}
diff --git a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx
index fb6a251..8d0d40e 100644
--- a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx
+++ b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx
@@ -8,10 +8,6 @@ import { LabCasesDispatchPanel } from '@/components/ui/treatment/LabCasesDispatc
import { PastTreatmentsPanel } from '@/components/ui/treatment/PastTreatmentsPanel';
import { TreatmentDetailsEditor } from '@/components/ui/treatment/TreatmentDetailsEditor';
import { TreatmentPreviewCard } from '@/components/ui/treatment/TreatmentPreviewCard';
-import {
- TreatmentPreviewDialog,
- type TreatmentPreviewMode,
-} from '@/components/ui/treatment/TreatmentPreviewDialog';
import { ToastStack } from '@/components/ui/shared/Toast';
import { treatmentTypeLabelKey } from '@/components/ui/treatment/treatmentTypeDisplay';
import {
@@ -40,6 +36,57 @@ import type {
TreatmentDetailDraft,
} from '@/types/treatment';
+type WorkspaceMode = 'live' | 'historical';
+
+function isTreatmentDayHistorical(treatmentAt: string, todayStart: Date): boolean {
+ return compareLocalDayStart(new Date(treatmentAt), todayStart) < 0;
+}
+
+function labCaseDraftsToPast(
+ labCaseDrafts: LabCaseDraft[],
+ details: TreatmentDetailDraft[],
+): PastLabCase[] {
+ return labCaseDrafts.map((lc) => ({
+ id: lc.id ?? lc.clientId,
+ clientId: lc.clientId,
+ destinationOrganizationId: lc.destinationOrganizationId,
+ labComment: lc.labComment || null,
+ sentAt: lc.sentAt ?? null,
+ treatmentDetailIds: lc.detailClientIds
+ .map((cid) => details.find((d) => d.clientId === cid)?.id)
+ .filter((id): id is string => Boolean(id)),
+ details: lc.detailClientIds.map((cid) => {
+ const d = details.find((x) => x.clientId === cid);
+ return {
+ id: d?.id ?? cid,
+ clientId: cid,
+ treatmentType: d?.treatmentType ?? 'consultation',
+ teeth: d?.teeth ?? [],
+ };
+ }),
+ sends: lc.sends ?? [],
+ }));
+}
+
+function buildWorkspaceSnapshot(
+ appointment: TreatmentAppointment,
+ details: TreatmentDetailDraft[],
+ labCaseDrafts: LabCaseDraft[],
+ title: string,
+ id?: string,
+): PastTreatment {
+ return {
+ ...detailsToPreviewTreatment(details, {
+ id: id ?? `preview-${appointment.id}`,
+ title,
+ patientId: appointment.patientId,
+ treatmentAt: appointment.startAt,
+ }),
+ appointmentId: appointment.id,
+ labCases: labCaseDraftsToPast(labCaseDrafts, details),
+ };
+}
+
function newDetail(): TreatmentDetailDraft {
return {
clientId:
@@ -134,14 +181,13 @@ function isDetailsDirty(
function detailsToPreviewTreatment(
details: TreatmentDetailDraft[],
- meta: { title: string; patientId: string; treatmentAt: string; status: string; id?: string },
+ meta: { title: string; patientId: string; treatmentAt: string; id?: string },
): PastTreatment {
return {
id: meta.id ?? 'current-draft',
patientId: meta.patientId,
title: meta.title,
treatmentAt: meta.treatmentAt,
- status: meta.status,
details: details.map((d, idx) => ({
id: d.id ?? d.clientId ?? `draft-${idx + 1}`,
clientId: d.clientId,
@@ -181,6 +227,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
const [history, setHistory] = useState([]);
const [historyLoading, setHistoryLoading] = useState(false);
+ const [historyPatientId, setHistoryPatientId] = useState(null);
const [orgs, setOrgs] = useState([]);
const [labDependentCodes, setLabDependentCodes] = useState>(new Set());
@@ -191,6 +238,8 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
const [activeLabCaseId, setActiveLabCaseId] = useState(null);
const [savedSnapshot, setSavedSnapshot] = useState(null);
const [saveStatus, setSaveStatus] = useState<'idle' | 'dirty' | 'saving' | 'saved' | 'error'>('idle');
+ const [selectedPreviewId, setSelectedPreviewId] = useState(null);
+ const [workspaceMode, setWorkspaceMode] = useState('live');
const selectionLockedRef = useRef(selectionLocked);
selectionLockedRef.current = selectionLocked;
@@ -203,16 +252,17 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
const saveInFlightRef = useRef(false);
const saveQueuedRef = useRef(false);
const draftHydratingRef = useRef(false);
+ const workspaceModeRef = useRef(workspaceMode);
+ workspaceModeRef.current = workspaceMode;
+ const labCaseDraftsRef = useRef(labCaseDrafts);
+ labCaseDraftsRef.current = labCaseDrafts;
+ const skipNextGetDraftRef = useRef(false);
const [sendBusyId, setSendBusyId] = useState(null);
const [uploadBusyDetailId, setUploadBusyDetailId] = useState(null);
const [organizationSearch, setOrganizationSearch] = useState('');
const [recentOrganizationIds, setRecentOrganizationIds] = useState([]);
- const [previewOpen, setPreviewOpen] = useState(false);
- const [previewTreatment, setPreviewTreatment] = useState(null);
- const [previewMode, setPreviewMode] = useState('readonly');
-
const isDetailLocked = useCallback(
(detail: TreatmentDetailDraft) =>
labCaseDrafts.some((lc) => lc.sentAt && lc.detailClientIds.includes(detail.clientId)),
@@ -236,7 +286,66 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
[selectedDay, todayStart],
);
- const canEditTreatmentForDay = canEdit && Boolean(selectedAppointment) && !isViewingPastDay;
+ const canEditTreatmentForDay =
+ canEdit &&
+ Boolean(selectedAppointment) &&
+ !isViewingPastDay &&
+ workspaceMode === 'live';
+
+ const historyPanelItems = useMemo(() => {
+ return history.filter((item) => {
+ if (
+ workspaceMode === 'live' &&
+ selectedAppointmentId &&
+ item.appointmentId === selectedAppointmentId
+ ) {
+ return false;
+ }
+ return true;
+ });
+ }, [history, selectedAppointmentId, workspaceMode]);
+
+ const currentDraftPreview = useMemo(() => {
+ if (!selectedAppointment) return null;
+ return buildWorkspaceSnapshot(
+ selectedAppointment,
+ details,
+ labCaseDrafts,
+ t('treatmentPlanTitle', {
+ patientName: `${selectedAppointment.patientFirstName} ${selectedAppointment.patientLastName}`,
+ }),
+ 'current-draft',
+ );
+ }, [details, labCaseDrafts, selectedAppointment, t]);
+
+ const previewTreatment = useMemo(() => {
+ if (!selectedPreviewId) return currentDraftPreview;
+ return historyPanelItems.find((item) => item.id === selectedPreviewId) ?? currentDraftPreview;
+ }, [selectedPreviewId, historyPanelItems, currentDraftPreview]);
+
+ const isPreviewAlreadyOpen = useMemo(() => {
+ if (!previewTreatment?.appointmentId || !selectedAppointmentId) return false;
+ if (selectedAppointmentId !== previewTreatment.appointmentId) return false;
+ if (workspaceMode === 'historical') return true;
+ if (workspaceMode === 'live' && selectedPreviewId === null) return true;
+ if (workspaceMode === 'live' && selectedPreviewId === previewTreatment.id) return true;
+ return false;
+ }, [previewTreatment, selectedAppointmentId, workspaceMode, selectedPreviewId]);
+
+ const hydrateFromTreatment = useCallback((treatment: PastTreatment) => {
+ const mapped = treatment.details.map(mapDetailFromApi);
+ setDetails(mapped);
+ setActiveDetailId((prev) => {
+ const stillExists = mapped.some((d) => d.clientId === prev);
+ return stillExists ? prev : mapped[0]?.clientId ?? prev;
+ });
+ setSavedSnapshot(serializeDetails(mapped));
+ const mappedLabCases = (treatment.labCases ?? []).map(mapLabCaseDraftFromApi);
+ setLabCaseDrafts(mappedLabCases);
+ setActiveLabCaseId(mappedLabCases[0]?.clientId ?? null);
+ setOrganizationSearch('');
+ setSaveStatus('idle');
+ }, []);
const activeDetail = useMemo(
() => details.find((d) => d.clientId === activeDetailId) ?? details[0],
@@ -245,18 +354,6 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
const selectedTeethSet = useMemo(() => new Set(activeDetail?.teeth ?? []), [activeDetail?.teeth]);
- const currentDraftPreview = useMemo(() => {
- if (!selectedAppointment) return null;
- return detailsToPreviewTreatment(details, {
- title: t('draftTitle', {
- patientName: `${selectedAppointment.patientFirstName} ${selectedAppointment.patientLastName}`,
- }),
- patientId: selectedAppointment.patientId,
- treatmentAt: new Date().toISOString(),
- status: 'draft',
- });
- }, [details, selectedAppointment, t]);
-
useEffect(() => {
setSelectionLocked(false);
}, [selectedDay]);
@@ -332,15 +429,18 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
}, [showError, t]);
useEffect(() => {
- if (!selectedAppointment) {
- setHistory([]);
- return;
+ if (selectedAppointment?.patientId) {
+ setHistoryPatientId(selectedAppointment.patientId);
}
+ }, [selectedAppointment?.patientId]);
+
+ useEffect(() => {
+ if (!historyPatientId) return;
let cancelled = false;
setHistoryLoading(true);
void (async () => {
try {
- const response = await treatmentsApi.listPatientHistory(selectedAppointment.patientId);
+ const response = await treatmentsApi.listPatientHistory(historyPatientId);
if (!cancelled) setHistory(response.data);
} catch (error: unknown) {
if (!cancelled) {
@@ -353,11 +453,16 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
return () => {
cancelled = true;
};
- }, [selectedAppointment?.patientId, showError, t]);
+ }, [historyPatientId, showError, t]);
useEffect(() => {
const appointmentId = selectedAppointment?.id;
- if (!appointmentId) return;
+ if (!appointmentId || workspaceMode !== 'live') return;
+
+ if (skipNextGetDraftRef.current) {
+ skipNextGetDraftRef.current = false;
+ return;
+ }
let cancelled = false;
draftHydratingRef.current = true;
@@ -405,7 +510,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
cancelled = true;
draftHydratingRef.current = false;
};
- }, [selectedAppointment?.id, showError, t]);
+ }, [selectedAppointment?.id, workspaceMode, showError, t]);
const persistDraft = useCallback(
async (options?: { force?: boolean }) => {
@@ -416,12 +521,11 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
if (!options?.force && !dirty) {
return detailsToPreviewTreatment(currentDetails, {
- title: t('draftTitle', {
+ title: t('treatmentPlanTitle', {
patientName: `${selectedAppointment.patientFirstName} ${selectedAppointment.patientLastName}`,
}),
patientId: selectedAppointment.patientId,
treatmentAt: selectedAppointment.startAt,
- status: 'draft',
});
}
@@ -479,13 +583,24 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
}
}, [selectedAppointment, persistDraft, showError, t]);
+ const refreshHistory = useCallback(async (patientId: string) => {
+ try {
+ const response = await treatmentsApi.listPatientHistory(patientId);
+ setHistory(response.data);
+ } catch (error: unknown) {
+ showError(formatApiErrorMessage(error, t('errorLoadHistory')));
+ }
+ }, [showError, t]);
+
const flushDraftSave = useCallback(async (): Promise => {
if (autosaveTimerRef.current) {
clearTimeout(autosaveTimerRef.current);
autosaveTimerRef.current = null;
}
- if (!selectedAppointment || !canEditTreatmentForDay) return true;
+ if (workspaceModeRef.current !== 'live' || !selectedAppointment || !canEditTreatmentForDay) {
+ return true;
+ }
while (saveInFlightRef.current) {
await new Promise((resolve) => setTimeout(resolve, 50));
@@ -497,11 +612,14 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
try {
await runDraftSave();
+ if (historyPatientId) {
+ await refreshHistory(historyPatientId);
+ }
return true;
} catch {
return window.confirm(t('confirmDiscard'));
}
- }, [selectedAppointment, canEditTreatmentForDay, runDraftSave, t]);
+ }, [selectedAppointment, canEditTreatmentForDay, runDraftSave, historyPatientId, refreshHistory, t]);
useEffect(() => {
if (draftHydratingRef.current || !canEditTreatmentForDay || !selectedAppointment?.id) {
@@ -528,16 +646,22 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
};
}, [details, isDirty, canEditTreatmentForDay, selectedAppointment?.id, runDraftSave]);
+ const resetToLiveContext = useCallback(() => {
+ setWorkspaceMode('live');
+ setSelectedPreviewId(null);
+ }, []);
+
const onPickAppointment = useCallback(
(id: string) => {
void (async () => {
const ok = await flushDraftSave();
if (!ok) return;
+ resetToLiveContext();
setSelectionLocked(true);
setSelectedAppointmentId(id);
})();
},
- [flushDraftSave],
+ [flushDraftSave, resetToLiveContext],
);
const onSelectDay = useCallback(
@@ -545,12 +669,56 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
void (async () => {
const ok = await flushDraftSave();
if (!ok) return;
+ const patientIdToRefresh = historyPatientId;
+ resetToLiveContext();
setSelectedDay(day);
+ if (patientIdToRefresh) {
+ await refreshHistory(patientIdToRefresh);
+ }
})();
},
- [flushDraftSave],
+ [flushDraftSave, resetToLiveContext, historyPatientId, refreshHistory],
);
+ const handleSelectPreviewTreatment = useCallback((treatment: PastTreatment) => {
+ setSelectedPreviewId(treatment.id);
+ }, []);
+
+ const handleOpenTreatment = useCallback(() => {
+ void (async () => {
+ const treatment = previewTreatment;
+ if (!treatment?.appointmentId) {
+ showError(t('errorNoAppointmentForTreatment'));
+ return;
+ }
+
+ if (isPreviewAlreadyOpen) return;
+
+ const ok = workspaceModeRef.current === 'live' ? await flushDraftSave() : true;
+ if (!ok) return;
+
+ const isHistorical = isTreatmentDayHistorical(treatment.treatmentAt, todayStart);
+ setWorkspaceMode(isHistorical ? 'historical' : 'live');
+ setSelectedPreviewId(treatment.id);
+ setSelectedDay(startOfLocalDay(new Date(treatment.treatmentAt)));
+ setSelectionLocked(true);
+ setSelectedAppointmentId(treatment.appointmentId);
+
+ skipNextGetDraftRef.current = true;
+ draftHydratingRef.current = true;
+ hydrateFromTreatment(treatment);
+ draftHydratingRef.current = false;
+ })();
+ }, [
+ previewTreatment,
+ isPreviewAlreadyOpen,
+ flushDraftSave,
+ hydrateFromTreatment,
+ showError,
+ t,
+ todayStart,
+ ]);
+
const uploadForDetail = useCallback(
async (detailClientId: string, files: FileList | File[]) => {
if (!canEditTreatmentForDay || !selectedAppointment) return;
@@ -685,17 +853,6 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
],
);
- const openPreview = useCallback((treatment: PastTreatment, mode: TreatmentPreviewMode) => {
- setPreviewTreatment(treatment);
- setPreviewMode(mode);
- setPreviewOpen(true);
- }, []);
-
- const openCurrentDraftPreview = useCallback(() => {
- if (!currentDraftPreview) return;
- openPreview(currentDraftPreview, canEditTreatmentForDay ? 'editable' : 'readonly');
- }, [currentDraftPreview, canEditTreatmentForDay, openPreview]);
-
if (!canView) {
return (
@@ -727,7 +884,13 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
loading={apptsLoading}
/>
- {isViewingPastDay && (
+ {workspaceMode === 'historical' && (
+
+ {t('historicalReadonlyNotice')}
+
+ )}
+
+ {isViewingPastDay && workspaceMode === 'live' && (
{t('pastDayNotice')}
@@ -757,15 +920,18 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
)}
openPreview(item, 'readonly')}
+ selectedPreviewId={selectedPreviewId}
+ onSelectTreatment={handleSelectPreviewTreatment}
/>
@@ -793,6 +959,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
onActiveDetailChange={setActiveDetailId}
onDetailsChange={setDetails}
isDetailLocked={isDetailLocked}
+ labDependentCodes={labDependentCodes}
disabled={!canEditTreatmentForDay}
canEdit={canEdit}
saveStatus={saveStatus}
@@ -802,7 +969,6 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
setDetails((prev) => [...prev, next]);
setActiveDetailId(next.clientId);
}}
- onPreview={openCurrentDraftPreview}
onUploadFiles={(files) => void uploadForDetail(activeDetailId, files ?? [])}
/>
@@ -839,18 +1005,6 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
/>
-
- setPreviewOpen(false)}
- treatment={
- previewMode === 'editable' && currentDraftPreview ? currentDraftPreview : previewTreatment
- }
- mode={previewMode}
- orgs={orgs}
- uploadBusyCaseId={uploadBusyDetailId}
- onAttach={(caseKey, files) => uploadForDetail(caseKey, files)}
- />
);
}
diff --git a/frontend/src/components/ui/treatment/treatmentStatusStyles.ts b/frontend/src/components/ui/treatment/treatmentStatusStyles.ts
new file mode 100644
index 0000000..e426ef0
--- /dev/null
+++ b/frontend/src/components/ui/treatment/treatmentStatusStyles.ts
@@ -0,0 +1,26 @@
+export const labSentBadgeClass =
+ 'inline-flex items-center rounded-[var(--radius-sm)] border border-emerald-500/40 bg-emerald-500/10 px-2 py-0.5 text-[11px] font-medium text-emerald-600 dark:text-emerald-400';
+
+export const labNotSentBadgeClass =
+ 'inline-flex items-center rounded-[var(--radius-sm)] border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 text-[11px] font-medium text-amber-600 dark:text-amber-400';
+
+export const labSentBannerClass =
+ 'text-xs rounded-[var(--radius-sm)] border border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-400 px-2 py-1.5';
+
+export const labPendingBannerClass =
+ 'text-xs rounded-[var(--radius-sm)] border border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-400 px-2 py-1.5';
+
+export function autosaveStatusClass(status: 'dirty' | 'saving' | 'saved' | 'error'): string {
+ switch (status) {
+ case 'dirty':
+ return 'text-amber-600 dark:text-amber-400';
+ case 'saving':
+ return 'text-text-muted animate-pulse';
+ case 'saved':
+ return 'text-emerald-600 dark:text-emerald-400';
+ case 'error':
+ return 'text-red-500';
+ default:
+ return 'text-text-muted';
+ }
+}
diff --git a/frontend/src/types/treatment.ts b/frontend/src/types/treatment.ts
index c511b29..e1ab7f1 100644
--- a/frontend/src/types/treatment.ts
+++ b/frontend/src/types/treatment.ts
@@ -108,7 +108,6 @@ export interface PastTreatment {
appointmentId?: string | null;
title: string;
treatmentAt: string;
- status: string;
details: PastTreatmentDetail[];
labCases: PastLabCase[];
documents: TreatmentAttachmentMeta[];
--
2.53.0.windows.1
From 2a48946a51b64a7bc3e3522d8ed71832eb047eff Mon Sep 17 00:00:00 2001
From: Admin
Date: Sun, 28 Jun 2026 22:56:56 +0330
Subject: [PATCH 10/17] feature: Tasks tab added for lab organizations. tasks
now can be assigned and their status can be updated by the assignee.
---
.../migration.sql | 22 ++
.../migration.sql | 6 +
backend/prisma/schema.prisma | 7 +
backend/prisma/seed.ts | 4 +
backend/src/app.module.ts | 2 +
backend/src/common/organization-type.ts | 7 +-
backend/src/common/permissions.ts | 3 +
backend/src/modules/auth/auth.service.ts | 2 +
backend/src/modules/cases/cases.controller.ts | 2 +-
backend/src/modules/cases/cases.service.ts | 53 +++-
backend/src/modules/cases/dto/cases.dto.ts | 12 +-
backend/src/modules/tasks/dto/tasks.dto.ts | 23 ++
backend/src/modules/tasks/tasks.controller.ts | 32 +++
backend/src/modules/tasks/tasks.module.ts | 9 +
backend/src/modules/tasks/tasks.service.ts | 188 +++++++++++++
frontend/messages/en.json | 29 +-
frontend/messages/fa.json | 29 +-
frontend/messages/nl.json | 29 +-
.../app/[locale]/(dashboard)/cases/page.tsx | 53 +++-
.../app/[locale]/(dashboard)/tasks/page.tsx | 258 ++++++++++++++++++
frontend/src/components/shared/permissions.ts | 35 +++
.../components/staff/staff-permission-form.ts | 1 +
.../src/components/ui/shared/Dropdown.tsx | 9 +-
frontend/src/components/ui/shared/Sidebar.tsx | 6 +
.../components/ui/shared/formSelectStyles.ts | 3 +
frontend/src/lib/api/cases.ts | 2 +-
frontend/src/lib/api/tasks.ts | 20 ++
frontend/src/styles/globals.css | 18 ++
frontend/src/types/cases.ts | 30 ++
29 files changed, 851 insertions(+), 43 deletions(-)
create mode 100644 backend/prisma/migrations/20260628180000_lab_task_priority_and_tasks_permissions/migration.sql
create mode 100644 backend/prisma/migrations/20260628190000_lab_task_assigned_at/migration.sql
create mode 100644 backend/src/modules/tasks/dto/tasks.dto.ts
create mode 100644 backend/src/modules/tasks/tasks.controller.ts
create mode 100644 backend/src/modules/tasks/tasks.module.ts
create mode 100644 backend/src/modules/tasks/tasks.service.ts
create mode 100644 frontend/src/app/[locale]/(dashboard)/tasks/page.tsx
create mode 100644 frontend/src/components/ui/shared/formSelectStyles.ts
create mode 100644 frontend/src/lib/api/tasks.ts
diff --git a/backend/prisma/migrations/20260628180000_lab_task_priority_and_tasks_permissions/migration.sql b/backend/prisma/migrations/20260628180000_lab_task_priority_and_tasks_permissions/migration.sql
new file mode 100644
index 0000000..1b2937d
--- /dev/null
+++ b/backend/prisma/migrations/20260628180000_lab_task_priority_and_tasks_permissions/migration.sql
@@ -0,0 +1,22 @@
+-- Add task priority, timestamps, and Tasks tab permissions
+
+ALTER TABLE "lab_case_tasks" ADD COLUMN "priority" INTEGER NOT NULL DEFAULT 3;
+ALTER TABLE "lab_case_tasks" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
+ALTER TABLE "lab_case_tasks" ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
+
+CREATE INDEX "lab_case_tasks_assigneeUserId_priority_createdAt_idx"
+ ON "lab_case_tasks"("assigneeUserId", "priority", "createdAt");
+
+INSERT INTO "features" ("id", "name", "description", "organizationTypeId")
+VALUES (gen_random_uuid(), 'Tasks', 'Lab task 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_TASKS_READ'),
+ ('TAB_TASKS_EDIT')
+) AS v(name)
+CROSS JOIN "features" f
+WHERE f.name = 'Tasks'
+ON CONFLICT ("name") DO NOTHING;
diff --git a/backend/prisma/migrations/20260628190000_lab_task_assigned_at/migration.sql b/backend/prisma/migrations/20260628190000_lab_task_assigned_at/migration.sql
new file mode 100644
index 0000000..e58a202
--- /dev/null
+++ b/backend/prisma/migrations/20260628190000_lab_task_assigned_at/migration.sql
@@ -0,0 +1,6 @@
+-- Track when a task was assigned (for sorting and display)
+
+ALTER TABLE "lab_case_tasks" ADD COLUMN "assignedAt" TIMESTAMP(3);
+
+CREATE INDEX "lab_case_tasks_assignedAt_labCaseId_priority_idx"
+ ON "lab_case_tasks"("assignedAt" DESC, "labCaseId" ASC, "priority" DESC);
diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma
index b81fde5..e453a29 100644
--- a/backend/prisma/schema.prisma
+++ b/backend/prisma/schema.prisma
@@ -253,14 +253,21 @@ model LabCaseTask {
stepOrder Int
stepLabel String
assigneeUserId String?
+ assignedAt DateTime?
+ priority Int @default(3)
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)
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
@@unique([labCaseId, tooth, treatmentType, stepOrder])
@@index([labCaseId, status])
+ @@index([assigneeUserId, priority, createdAt])
+ @@index([assignedAt, labCaseId, priority])
@@map("lab_case_tasks")
}
diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts
index d111482..ad968ed 100644
--- a/backend/prisma/seed.ts
+++ b/backend/prisma/seed.ts
@@ -94,6 +94,10 @@ async function main() {
name: 'Cases',
permissions: ['TAB_CASES_READ', 'TAB_CASES_EDIT'],
},
+ {
+ name: 'Tasks',
+ permissions: ['TAB_TASKS_READ', 'TAB_TASKS_EDIT'],
+ },
{
name: 'Billing',
permissions: ['TAB_BILLING_READ', 'TAB_BILLING_EDIT'],
diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts
index 3b7d8ec..e67e7f2 100644
--- a/backend/src/app.module.ts
+++ b/backend/src/app.module.ts
@@ -12,6 +12,7 @@ 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 { TasksModule } from './modules/tasks/tasks.module';
import { TreatmentCatalogModule } from './modules/treatment-catalog/treatment-catalog.module';
@Module({
@@ -27,6 +28,7 @@ import { TreatmentCatalogModule } from './modules/treatment-catalog/treatment-ca
AppointmentsModule,
TreatmentsModule,
CasesModule,
+ TasksModule,
StaffModule,
OrganizationModule,
AdminModule.forRoot(),
diff --git a/backend/src/common/organization-type.ts b/backend/src/common/organization-type.ts
index d8b59ce..6ddc33a 100644
--- a/backend/src/common/organization-type.ts
+++ b/backend/src/common/organization-type.ts
@@ -13,7 +13,12 @@ const CLINIC_ONLY_PERMISSIONS = new Set([
'TAB_TREATMENT_EDIT',
]);
-const LAB_ONLY_PERMISSIONS = new Set(['TAB_CASES_READ', 'TAB_CASES_EDIT']);
+const LAB_ONLY_PERMISSIONS = new Set([
+ 'TAB_CASES_READ',
+ 'TAB_CASES_EDIT',
+ 'TAB_TASKS_READ',
+ 'TAB_TASKS_EDIT',
+]);
const SHARED_PERMISSIONS = ALL_TAB_PERMISSIONS.filter(
(p) => !CLINIC_ONLY_PERMISSIONS.has(p) && !LAB_ONLY_PERMISSIONS.has(p),
diff --git a/backend/src/common/permissions.ts b/backend/src/common/permissions.ts
index 4a6f6c8..bd85f5b 100644
--- a/backend/src/common/permissions.ts
+++ b/backend/src/common/permissions.ts
@@ -14,6 +14,8 @@ export const ALL_TAB_PERMISSIONS = [
'TAB_TREATMENT_EDIT',
'TAB_CASES_READ',
'TAB_CASES_EDIT',
+ 'TAB_TASKS_READ',
+ 'TAB_TASKS_EDIT',
'TAB_BILLING_READ',
'TAB_BILLING_EDIT',
'TAB_REPORTS_READ',
@@ -43,6 +45,7 @@ const EDIT_TO_READ: Record = {
TAB_ORGANIZATIONS_EDIT: 'TAB_ORGANIZATIONS_READ',
TAB_TREATMENT_EDIT: 'TAB_TREATMENT_READ',
TAB_CASES_EDIT: 'TAB_CASES_READ',
+ TAB_TASKS_EDIT: 'TAB_TASKS_READ',
TAB_BILLING_EDIT: 'TAB_BILLING_READ',
TAB_REPORTS_EDIT: 'TAB_REPORTS_READ',
};
diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts
index 5e1b861..95162ea 100644
--- a/backend/src/modules/auth/auth.service.ts
+++ b/backend/src/modules/auth/auth.service.ts
@@ -37,6 +37,8 @@ const ALL_PERMISSIONS = [
'TAB_TREATMENT_EDIT',
'TAB_CASES_READ',
'TAB_CASES_EDIT',
+ 'TAB_TASKS_READ',
+ 'TAB_TASKS_EDIT',
'TAB_BILLING_READ',
'TAB_BILLING_EDIT',
'TAB_REPORTS_READ',
diff --git a/backend/src/modules/cases/cases.controller.ts b/backend/src/modules/cases/cases.controller.ts
index 7c3a967..d463022 100644
--- a/backend/src/modules/cases/cases.controller.ts
+++ b/backend/src/modules/cases/cases.controller.ts
@@ -50,7 +50,7 @@ export class CasesController {
}
@Patch(':id/tasks/:taskId')
- @ApiOperation({ summary: 'Update task assignee or status' })
+ @ApiOperation({ summary: 'Update task assignee or priority' })
updateTask(
@Param('id') id: string,
@Param('taskId') taskId: string,
diff --git a/backend/src/modules/cases/cases.service.ts b/backend/src/modules/cases/cases.service.ts
index 2ea727a..940ccc9 100644
--- a/backend/src/modules/cases/cases.service.ts
+++ b/backend/src/modules/cases/cases.service.ts
@@ -200,14 +200,19 @@ export class CasesService {
}
if (dto.assigneeUserId !== undefined && dto.assigneeUserId !== null) {
- await this.ensureLabMember(dto.assigneeUserId, labOrganizationId);
+ await this.ensureAssignableMember(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 } : {}),
+ ...(dto.assigneeUserId !== undefined
+ ? {
+ assigneeUserId: dto.assigneeUserId,
+ assignedAt: dto.assigneeUserId === null ? null : new Date(),
+ }
+ : {}),
+ ...(dto.priority !== undefined ? { priority: dto.priority } : {}),
},
include: {
assignee: { select: { id: true, name: true, email: true } },
@@ -222,18 +227,27 @@ export class CasesService {
const memberships = await this.prisma.membership.findMany({
where: { organizationId: labOrganizationId, isActive: true },
- include: { user: { select: { id: true, name: true, email: true } } },
+ include: {
+ user: { select: { id: true, name: true, email: true } },
+ permissions: { include: { permission: 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,
- })),
+ data: memberships
+ .filter((m) => {
+ if (m.isOwner) return true;
+ const names = m.permissions.map((p) => p.permission.name);
+ return names.includes('TAB_TASKS_READ') || names.includes('TAB_TASKS_EDIT');
+ })
+ .map((m) => ({
+ userId: m.user.id,
+ name: m.user.name,
+ email: m.user.email,
+ isOwner: m.isOwner,
+ })),
};
}
@@ -377,7 +391,10 @@ export class CasesService {
stepOrder: number;
stepLabel: string;
status: LabTaskStatus;
+ priority: number;
assigneeUserId: string | null;
+ assignedAt: Date | null;
+ createdAt: Date;
assignee: { id: string; name: string; email: string } | null;
}>,
) {
@@ -411,7 +428,10 @@ export class CasesService {
stepOrder: number;
stepLabel: string;
status: LabTaskStatus;
+ priority: number;
assigneeUserId: string | null;
+ assignedAt: Date | null;
+ createdAt: Date;
assignee: { id: string; name: string; email: string } | null;
}) {
return {
@@ -421,6 +441,9 @@ export class CasesService {
stepOrder: task.stepOrder,
stepLabel: task.stepLabel,
status: task.status,
+ priority: task.priority,
+ assignedAt: task.assignedAt?.toISOString() ?? null,
+ createdAt: task.createdAt.toISOString(),
assigneeUserId: task.assigneeUserId,
assignee: task.assignee
? { id: task.assignee.id, name: task.assignee.name, email: task.assignee.email }
@@ -428,14 +451,20 @@ export class CasesService {
};
}
- private async ensureLabMember(userId: string, labOrganizationId: string) {
+ private async ensureAssignableMember(userId: string, labOrganizationId: string) {
const membership = await this.prisma.membership.findFirst({
where: { userId, organizationId: labOrganizationId, isActive: true },
- select: { id: true },
+ include: { permissions: { include: { permission: true } } },
});
if (!membership) {
throw new BadRequestException('Assignee must be an active member of this lab');
}
+ if (membership.isOwner) return;
+ const names = membership.permissions.map((p) => p.permission.name);
+ if (names.includes('TAB_TASKS_READ') || names.includes('TAB_TASKS_EDIT')) {
+ return;
+ }
+ throw new BadRequestException('Assignee must have access to the Tasks tab');
}
private async assertCanReadCases(userId: string, organizationId: string) {
diff --git a/backend/src/modules/cases/dto/cases.dto.ts b/backend/src/modules/cases/dto/cases.dto.ts
index 438da73..e7ca9bb 100644
--- a/backend/src/modules/cases/dto/cases.dto.ts
+++ b/backend/src/modules/cases/dto/cases.dto.ts
@@ -1,6 +1,5 @@
import { Transform } from 'class-transformer';
-import { IsDateString, IsEnum, IsInt, IsOptional, IsString, IsUUID, Max, Min, ValidateIf } from 'class-validator';
-import { LabTaskStatus } from '@prisma/client';
+import { IsDateString, IsInt, IsOptional, IsString, IsUUID, Max, Min, ValidateIf } from 'class-validator';
export class UpdateLabCaseTaskDto {
@IsOptional()
@@ -9,8 +8,11 @@ export class UpdateLabCaseTaskDto {
assigneeUserId?: string | null;
@IsOptional()
- @IsEnum(LabTaskStatus)
- status?: LabTaskStatus;
+ @Transform(({ value }) => Number(value))
+ @IsInt()
+ @Min(1)
+ @Max(5)
+ priority?: number;
}
export class ListLabCasesDto {
@@ -46,4 +48,4 @@ export class ListLabCasesDto {
@Min(1)
@Max(100)
limit = 20;
-}
\ No newline at end of file
+}
diff --git a/backend/src/modules/tasks/dto/tasks.dto.ts b/backend/src/modules/tasks/dto/tasks.dto.ts
new file mode 100644
index 0000000..1b5539d
--- /dev/null
+++ b/backend/src/modules/tasks/dto/tasks.dto.ts
@@ -0,0 +1,23 @@
+import { IsEnum, IsInt, IsOptional, Max, Min } from 'class-validator';
+import { Transform } from 'class-transformer';
+import { LabTaskStatus } from '@prisma/client';
+
+export class UpdateLabTaskDto {
+ @IsEnum(LabTaskStatus)
+ status: LabTaskStatus;
+}
+
+export class ListLabTasksDto {
+ @IsOptional()
+ @Transform(({ value }) => Number(value))
+ @IsInt()
+ @Min(1)
+ page = 1;
+
+ @IsOptional()
+ @Transform(({ value }) => Number(value))
+ @IsInt()
+ @Min(1)
+ @Max(100)
+ limit = 50;
+}
diff --git a/backend/src/modules/tasks/tasks.controller.ts b/backend/src/modules/tasks/tasks.controller.ts
new file mode 100644
index 0000000..a2125e4
--- /dev/null
+++ b/backend/src/modules/tasks/tasks.controller.ts
@@ -0,0 +1,32 @@
+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 { ListLabTasksDto, UpdateLabTaskDto } from './dto/tasks.dto';
+import { TasksService } from './tasks.service';
+
+@ApiTags('tasks')
+@ApiBearerAuth('JWT-auth')
+@UseGuards(JwtAuthGuard, LabOrgGuard)
+@Controller('tasks')
+export class TasksController {
+ constructor(private readonly tasksService: TasksService) {}
+
+ @Get()
+ @ApiOperation({ summary: 'List lab tasks (owner: all, staff: assigned only)' })
+ list(@Query() query: ListLabTasksDto, @Req() req) {
+ const organizationId = this.tasksService.getOrganizationIdFromUser(req.user);
+ return this.tasksService.list(organizationId, req.user.id, query);
+ }
+
+ @Patch(':taskId')
+ @ApiOperation({ summary: 'Update task status' })
+ updateStatus(
+ @Param('taskId') taskId: string,
+ @Body() dto: UpdateLabTaskDto,
+ @Req() req,
+ ) {
+ const organizationId = this.tasksService.getOrganizationIdFromUser(req.user);
+ return this.tasksService.updateStatus(taskId, dto, organizationId, req.user.id);
+ }
+}
diff --git a/backend/src/modules/tasks/tasks.module.ts b/backend/src/modules/tasks/tasks.module.ts
new file mode 100644
index 0000000..76ba304
--- /dev/null
+++ b/backend/src/modules/tasks/tasks.module.ts
@@ -0,0 +1,9 @@
+import { Module } from '@nestjs/common';
+import { TasksController } from './tasks.controller';
+import { TasksService } from './tasks.service';
+
+@Module({
+ controllers: [TasksController],
+ providers: [TasksService],
+})
+export class TasksModule {}
diff --git a/backend/src/modules/tasks/tasks.service.ts b/backend/src/modules/tasks/tasks.service.ts
new file mode 100644
index 0000000..ce7872a
--- /dev/null
+++ b/backend/src/modules/tasks/tasks.service.ts
@@ -0,0 +1,188 @@
+import {
+ BadRequestException,
+ ForbiddenException,
+ Injectable,
+ NotFoundException,
+} from '@nestjs/common';
+import { LabTaskStatus, Prisma } from '@prisma/client';
+import { PrismaService } from '../../../prisma/prisma.service';
+import { ListLabTasksDto, UpdateLabTaskDto } from './dto/tasks.dto';
+
+const taskListInclude = {
+ assignee: { select: { id: true, name: true, email: true } },
+ labCase: {
+ include: {
+ treatment: {
+ include: {
+ organization: { select: { id: true, name: true } },
+ patient: { select: { id: true, firstName: true, lastName: true } },
+ },
+ },
+ },
+ },
+} satisfies Prisma.LabCaseTaskInclude;
+
+@Injectable()
+export class TasksService {
+ constructor(private readonly prisma: PrismaService) {}
+
+ getOrganizationIdFromUser(user: { organizationId?: string }) {
+ if (!user?.organizationId) {
+ throw new BadRequestException('Organization is not selected');
+ }
+ return user.organizationId;
+ }
+
+ async list(labOrganizationId: string, actorUserId: string, query: ListLabTasksDto) {
+ await this.assertCanReadTasks(actorUserId, labOrganizationId);
+
+ const membership = await this.getMembership(actorUserId, labOrganizationId);
+ if (!membership) {
+ throw new ForbiddenException('You are not a member of this organization');
+ }
+
+ const page = query.page ?? 1;
+ const limit = Math.min(Math.max(query.limit ?? 50, 1), 100);
+ const skip = (page - 1) * limit;
+
+ const where: Prisma.LabCaseTaskWhereInput = {
+ labCase: {
+ sentAt: { not: null },
+ sends: { some: { organizationId: labOrganizationId } },
+ },
+ ...(membership.isOwner ? {} : { assigneeUserId: actorUserId }),
+ };
+
+ const [items, total] = await Promise.all([
+ this.prisma.labCaseTask.findMany({
+ where,
+ include: taskListInclude,
+ orderBy: [
+ { assignedAt: { sort: 'desc', nulls: 'first' } },
+ { createdAt: 'desc' },
+ { labCaseId: 'asc' },
+ { priority: 'desc' },
+ { stepOrder: 'asc' },
+ { id: 'asc' },
+ ],
+ skip,
+ take: limit,
+ }),
+ this.prisma.labCaseTask.count({ where }),
+ ]);
+
+ return {
+ success: true,
+ data: {
+ items: items.map((task) => this.mapTaskListItem(task)),
+ pagination: {
+ page,
+ limit,
+ total,
+ totalPages: Math.max(1, Math.ceil(total / limit)),
+ },
+ },
+ };
+ }
+
+ async updateStatus(
+ taskId: string,
+ dto: UpdateLabTaskDto,
+ labOrganizationId: string,
+ actorUserId: string,
+ ) {
+ await this.assertCanEditTasks(actorUserId, labOrganizationId);
+
+ const membership = await this.getMembership(actorUserId, labOrganizationId);
+ if (!membership) {
+ throw new ForbiddenException('You are not a member of this organization');
+ }
+
+ const task = await this.prisma.labCaseTask.findFirst({
+ where: {
+ id: taskId,
+ labCase: {
+ sentAt: { not: null },
+ sends: { some: { organizationId: labOrganizationId } },
+ },
+ },
+ include: taskListInclude,
+ });
+
+ if (!task) {
+ throw new NotFoundException('Task not found');
+ }
+
+ if (!membership.isOwner && task.assigneeUserId !== actorUserId) {
+ throw new ForbiddenException('You can only update tasks assigned to you');
+ }
+
+ const updated = await this.prisma.labCaseTask.update({
+ where: { id: taskId },
+ data: { status: dto.status },
+ include: taskListInclude,
+ });
+
+ return { success: true, data: this.mapTaskListItem(updated) };
+ }
+
+ private mapTaskListItem(
+ task: Prisma.LabCaseTaskGetPayload<{ include: typeof taskListInclude }>,
+ ) {
+ return {
+ id: task.id,
+ labCaseId: task.labCaseId,
+ tooth: task.tooth,
+ treatmentType: task.treatmentType,
+ stepOrder: task.stepOrder,
+ stepLabel: task.stepLabel,
+ status: task.status,
+ priority: task.priority,
+ assignedAt: task.assignedAt?.toISOString() ?? null,
+ createdAt: task.createdAt.toISOString(),
+ assigneeUserId: task.assigneeUserId,
+ assignee: task.assignee
+ ? { id: task.assignee.id, name: task.assignee.name, email: task.assignee.email }
+ : null,
+ clinic: task.labCase.treatment.organization,
+ patient: {
+ id: task.labCase.treatment.patient.id,
+ firstName: task.labCase.treatment.patient.firstName,
+ lastName: task.labCase.treatment.patient.lastName,
+ },
+ };
+ }
+
+ private async assertCanReadTasks(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_TASKS_READ') || names.includes('TAB_TASKS_EDIT')) {
+ return;
+ }
+ throw new ForbiddenException('You do not have access to tasks');
+ }
+
+ private async assertCanEditTasks(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_TASKS_EDIT')) {
+ return;
+ }
+ throw new ForbiddenException('You cannot update tasks');
+ }
+
+ 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/frontend/messages/en.json b/frontend/messages/en.json
index 138c125..e482d97 100644
--- a/frontend/messages/en.json
+++ b/frontend/messages/en.json
@@ -52,6 +52,7 @@
"appointment": "Appointment",
"treatment": "Treatment",
"cases": "Cases",
+ "tasks": "Tasks",
"billing": "Billing",
"reports": "Reports",
"clinics": "Clinics",
@@ -263,6 +264,7 @@
"featureAppointment": "Appointment",
"featureTreatment": "Treatment",
"featureCases": "Cases",
+ "featureTasks": "Tasks",
"featureBilling": "Billing",
"featureReports": "Reports",
"noTabAccess": "No tab access",
@@ -348,7 +350,32 @@
"labComment": "Lab comment",
"prevPage": "Previous",
"nextPage": "Next",
- "pageSummary": "Page {page} of {totalPages} ({total} cases)"
+ "pageSummary": "Page {page} of {totalPages} ({total} cases)",
+ "priorityLabel": "Priority",
+ "statusLabel": "Status"
+ },
+ "tasks": {
+ "title": "Tasks",
+ "subtitle": "Your assigned lab tasks. Update status as you work through each step.",
+ "subtitleOwner": "All lab tasks in the organization. Assign tasks from Cases; update status on your own assignments here.",
+ "loading": "Loading tasks…",
+ "emptyList": "No tasks assigned to you yet.",
+ "emptyListOwner": "No tasks in the lab inbox yet.",
+ "noPermissionTitle": "Tasks",
+ "noPermissionBody": "You do not have permission to view tasks for this organization.",
+ "fromClinic": "From {name}",
+ "patientLabel": "Patient",
+ "taskDate": "{date}",
+ "priorityLabel": "Priority {n}",
+ "toothLabel": "Tooth {tooth}",
+ "unassigned": "Unassigned",
+ "assignedTo": "Assigned to {name}",
+ "statusPending": "Pending",
+ "statusInProgress": "In progress",
+ "statusCompleted": "Completed",
+ "errorLoadList": "Failed to load tasks.",
+ "errorUpdateTask": "Failed to update task.",
+ "pageSummary": "Page {page} of {totalPages} ({total} tasks)"
},
"appointments": {
"title": "Appointments",
diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json
index d2a4b92..39012cc 100644
--- a/frontend/messages/fa.json
+++ b/frontend/messages/fa.json
@@ -52,6 +52,7 @@
"appointment": "نوبتها",
"treatment": "درمان",
"cases": "پروندهها",
+ "tasks": "وظایف",
"billing": "صورتحساب",
"reports": "گزارشها",
"clinics": "کلینیکها",
@@ -263,6 +264,7 @@
"featureAppointment": "نوبتها",
"featureTreatment": "درمان",
"featureCases": "پروندهها",
+ "featureTasks": "وظایف",
"featureBilling": "صورتحساب",
"featureReports": "گزارشها",
"noTabAccess": "دسترسی به برگهها وجود ندارد",
@@ -348,7 +350,32 @@
"labComment": "یادداشت آزمایشگاه",
"prevPage": "قبلی",
"nextPage": "بعدی",
- "pageSummary": "صفحه {page} از {totalPages} ({total} پرونده)"
+ "pageSummary": "صفحه {page} از {totalPages} ({total} پرونده)",
+ "priorityLabel": "اولویت",
+ "statusLabel": "وضعیت"
+ },
+ "tasks": {
+ "title": "وظایف",
+ "subtitle": "وظایف لاب اختصاصیافته به شما. وضعیت را در حین انجام هر مرحله بهروز کنید.",
+ "subtitleOwner": "همه وظایف لاب در سازمان. تخصیص از بخش پروندهها؛ بهروزرسانی وضعیت برای وظایف خودتان اینجا.",
+ "loading": "در حال بارگذاری وظایف…",
+ "emptyList": "هنوز وظیفهای به شما اختصاص داده نشده است.",
+ "emptyListOwner": "هنوز وظیفهای در صندوق ورودی لاب وجود ندارد.",
+ "noPermissionTitle": "وظایف",
+ "noPermissionBody": "شما مجوز مشاهده وظایف برای این سازمان را ندارید.",
+ "fromClinic": "از {name}",
+ "patientLabel": "بیمار",
+ "taskDate": "{date}",
+ "priorityLabel": "اولویت {n}",
+ "toothLabel": "دندان {tooth}",
+ "unassigned": "اختصاص داده نشده",
+ "assignedTo": "اختصاص به {name}",
+ "statusPending": "در انتظار",
+ "statusInProgress": "در حال انجام",
+ "statusCompleted": "تکمیلشده",
+ "errorLoadList": "بارگذاری وظایف ناموفق بود.",
+ "errorUpdateTask": "بهروزرسانی وظیفه ناموفق بود.",
+ "pageSummary": "صفحه {page} از {totalPages} ({total} وظیفه)"
},
"appointments": {
"title": "نوبتها",
diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json
index 714ffb5..e878c98 100644
--- a/frontend/messages/nl.json
+++ b/frontend/messages/nl.json
@@ -52,6 +52,7 @@
"appointment": "Afspraak",
"treatment": "Behandeling",
"cases": "Dossiers",
+ "tasks": "Taken",
"billing": "Facturatie",
"reports": "Rapporten",
"clinics": "Klinieken",
@@ -263,6 +264,7 @@
"featureAppointment": "Afspraak",
"featureTreatment": "Behandeling",
"featureCases": "Dossiers",
+ "featureTasks": "Taken",
"featureBilling": "Facturatie",
"featureReports": "Rapporten",
"noTabAccess": "Geen tabbladtoegang",
@@ -348,7 +350,32 @@
"labComment": "Labnotitie",
"prevPage": "Vorige",
"nextPage": "Volgende",
- "pageSummary": "Pagina {page} van {totalPages} ({total} dossiers)"
+ "pageSummary": "Pagina {page} van {totalPages} ({total} dossiers)",
+ "priorityLabel": "Prioriteit",
+ "statusLabel": "Status"
+ },
+ "tasks": {
+ "title": "Taken",
+ "subtitle": "Uw toegewezen labtaken. Werk de status bij terwijl u elke stap uitvoert.",
+ "subtitleOwner": "Alle labtaken in de organisatie. Wijs toe via Dossiers; werk hier de status bij voor uw eigen taken.",
+ "loading": "Taken laden…",
+ "emptyList": "Nog geen taken aan u toegewezen.",
+ "emptyListOwner": "Nog geen taken in de lab-inbox.",
+ "noPermissionTitle": "Taken",
+ "noPermissionBody": "U heeft geen toestemming om taken voor deze organisatie te bekijken.",
+ "fromClinic": "Van {name}",
+ "patientLabel": "Patiënt",
+ "taskDate": "{date}",
+ "priorityLabel": "Prioriteit {n}",
+ "toothLabel": "Tand {tooth}",
+ "unassigned": "Niet toegewezen",
+ "assignedTo": "Toegewezen aan {name}",
+ "statusPending": "In afwachting",
+ "statusInProgress": "Bezig",
+ "statusCompleted": "Voltooid",
+ "errorLoadList": "Taken laden mislukt.",
+ "errorUpdateTask": "Taak bijwerken mislukt.",
+ "pageSummary": "Pagina {page} van {totalPages} ({total} taken)"
},
"appointments": {
"title": "Afspraken",
diff --git a/frontend/src/app/[locale]/(dashboard)/cases/page.tsx b/frontend/src/app/[locale]/(dashboard)/cases/page.tsx
index 207b4c4..9dddf11 100644
--- a/frontend/src/app/[locale]/(dashboard)/cases/page.tsx
+++ b/frontend/src/app/[locale]/(dashboard)/cases/page.tsx
@@ -1,14 +1,17 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
+import { useSearchParams } from 'next/navigation';
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 { canEditCases } from '@/components/shared/permissions';
+import { Badge, type BadgeVariant } from '@/components/ui/shared/Badge';
import { casesApi } from '@/lib/api/cases';
import { Button } from '@/components/ui/shared/Button';
+import { FORM_SELECT_CLASS } from '@/components/ui/shared/formSelectStyles';
import { SearchBar } from '@/components/ui/shared/SearchBar';
import type {
AssignableMember,
@@ -28,6 +31,18 @@ const TREATMENT_TYPE_KEYS = {
} as const;
const PAGE_SIZE = 20;
+const PRIORITY_OPTIONS = [1, 2, 3, 4, 5] as const;
+
+function taskStatusVariant(status: LabTaskStatus): BadgeVariant {
+ switch (status) {
+ case 'COMPLETED':
+ return 'success';
+ case 'IN_PROGRESS':
+ return 'default';
+ default:
+ return 'warning';
+ }
+}
function formatPatientName(patient: { firstName: string; lastName: string }) {
return `${patient.firstName} ${patient.lastName}`.trim();
@@ -66,6 +81,7 @@ export default function CasesPage() {
const tCommon = useTranslations('common');
const { currentOrganization, user } = useAuth();
const toast = useToast();
+ const searchParams = useSearchParams();
const [search, setSearch] = useState('');
const [clinicId, setClinicId] = useState('');
@@ -93,7 +109,7 @@ export default function CasesPage() {
const [loadingDetail, setLoadingDetail] = useState(false);
const [updatingTaskId, setUpdatingTaskId] = useState(null);
- const canEdit = hasPermission(currentOrganization, 'TAB_CASES_EDIT');
+ const canEdit = canEditCases(currentOrganization);
const locale = user?.language ?? 'en';
const treatmentLabel = useCallback(
@@ -166,6 +182,13 @@ export default function CasesPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only initial fetch
}, []);
+ useEffect(() => {
+ const caseIdFromUrl = searchParams.get('caseId');
+ if (caseIdFromUrl) {
+ setSelectedCaseId(caseIdFromUrl);
+ }
+ }, [searchParams]);
+
useEffect(() => {
const timeout = setTimeout(() => {
void loadCases({
@@ -201,7 +224,7 @@ export default function CasesPage() {
async function handleTaskUpdate(
taskId: string,
- payload: { assigneeUserId?: string | null; status?: LabTaskStatus },
+ payload: { assigneeUserId?: string | null; priority?: number },
) {
if (!selectedCaseId || !canEdit) return;
@@ -225,8 +248,7 @@ export default function CasesPage() {
}
}
- const filterSelectClass =
- 'w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-text-primary';
+ const filterSelectClass = `${FORM_SELECT_CLASS} w-full rounded-md px-3 py-2`;
return (
@@ -475,24 +497,29 @@ export default function CasesPage() {
{group.tasks.map((task) => (
{task.stepOrder}. {task.stepLabel}
+
+ {statusOptions.find((opt) => opt.value === task.status)?.label ??
+ task.status}
+
void handleTaskUpdate(task.id, {
- status: e.target.value as LabTaskStatus,
+ priority: Number(e.target.value),
})
}
- className="rounded border border-border bg-surface px-2 py-1 text-sm disabled:opacity-60"
+ className={FORM_SELECT_CLASS}
+ aria-label={t('priorityLabel')}
>
- {statusOptions.map((opt) => (
-
- {opt.label}
+ {PRIORITY_OPTIONS.map((value) => (
+
+ {value}
))}
@@ -504,7 +531,7 @@ export default function CasesPage() {
assigneeUserId: e.target.value || null,
})
}
- className="rounded border border-border bg-surface px-2 py-1 text-sm disabled:opacity-60"
+ className={FORM_SELECT_CLASS}
>
{t('unassigned')}
{members.map((member) => (
diff --git a/frontend/src/app/[locale]/(dashboard)/tasks/page.tsx b/frontend/src/app/[locale]/(dashboard)/tasks/page.tsx
new file mode 100644
index 0000000..775321c
--- /dev/null
+++ b/frontend/src/app/[locale]/(dashboard)/tasks/page.tsx
@@ -0,0 +1,258 @@
+'use client';
+
+import { useEffect, useMemo, useRef, useState } from 'react';
+import { useTranslations } from 'next-intl';
+import { ToastStack } from '@/components/ui/shared/Toast';
+import { Badge, type BadgeVariant } from '@/components/ui/shared/Badge';
+import { Button } from '@/components/ui/shared/Button';
+import { FORM_SELECT_CLASS } from '@/components/ui/shared/formSelectStyles';
+import { TreatmentTypeBadge } from '@/components/ui/treatment/TreatmentTypeBadge';
+import { formatApiErrorMessage } from '@/components/shared/formatApiError';
+import { canEditTasks, canViewTasks } from '@/components/shared/permissions';
+import { useAuth } from '@/lib/hooks/useAuth';
+import { useToast } from '@/lib/hooks/useToast';
+import { tasksApi } from '@/lib/api/tasks';
+import type { LabTaskListItem, LabTaskStatus, PaginatedLabTasks } from '@/types/cases';
+
+const PAGE_SIZE = 50;
+
+function taskStatusVariant(status: LabTaskStatus): BadgeVariant {
+ switch (status) {
+ case 'COMPLETED':
+ return 'success';
+ case 'IN_PROGRESS':
+ return 'default';
+ default:
+ return 'warning';
+ }
+}
+
+function formatPatientName(patient: { firstName: string; lastName: string }) {
+ return `${patient.firstName} ${patient.lastName}`.trim();
+}
+
+export default function TasksPage() {
+ const t = useTranslations('tasks');
+ const { currentOrganization, user, isAuthReady } = useAuth();
+ const { showError, setError, messages: toastMessages } = useToast();
+
+ const [tasks, setTasks] = useState([]);
+ const [pagination, setPagination] = useState({
+ page: 1,
+ limit: PAGE_SIZE,
+ total: 0,
+ totalPages: 1,
+ });
+ const [page, setPage] = useState(1);
+ const [loading, setLoading] = useState(false);
+ const [updatingTaskId, setUpdatingTaskId] = useState(null);
+
+ const canView = canViewTasks(currentOrganization);
+ const canEdit = canEditTasks(currentOrganization);
+ const locale = user?.language ?? 'en';
+ const isOwner = Boolean(currentOrganization?.isOwner);
+
+ const tRef = useRef(t);
+ tRef.current = t;
+
+ const statusOptions: { value: LabTaskStatus; label: string }[] = useMemo(
+ () => [
+ { value: 'PENDING', label: t('statusPending') },
+ { value: 'IN_PROGRESS', label: t('statusInProgress') },
+ { value: 'COMPLETED', label: t('statusCompleted') },
+ ],
+ [t],
+ );
+
+ useEffect(() => {
+ if (!canView) return;
+
+ let cancelled = false;
+
+ void (async () => {
+ setLoading(true);
+ setError('');
+ try {
+ const response = await tasksApi.list({ page, limit: PAGE_SIZE });
+ if (cancelled) return;
+ setTasks(response.data.items);
+ setPagination(response.data.pagination);
+ } catch (error: unknown) {
+ if (cancelled) return;
+ showError(formatApiErrorMessage(error, tRef.current('errorLoadList')));
+ } finally {
+ if (!cancelled) setLoading(false);
+ }
+ })();
+
+ return () => {
+ cancelled = true;
+ };
+ }, [canView, page, showError, setError]);
+
+ async function handleStatusUpdate(taskId: string, status: LabTaskStatus) {
+ if (!canEdit) return;
+
+ setUpdatingTaskId(taskId);
+ setError('');
+ try {
+ await tasksApi.updateStatus(taskId, status);
+ const response = await tasksApi.list({ page, limit: PAGE_SIZE });
+ setTasks(response.data.items);
+ setPagination(response.data.pagination);
+ } catch (error: unknown) {
+ showError(formatApiErrorMessage(error, t('errorUpdateTask')));
+ } finally {
+ setUpdatingTaskId(null);
+ }
+ }
+
+ function formatTaskDate(value: string) {
+ return new Intl.DateTimeFormat(locale, {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ }).format(new Date(value));
+ }
+
+ function sortDateForTask(task: LabTaskListItem) {
+ return task.assignedAt ?? task.createdAt;
+ }
+
+ if (!isAuthReady) {
+ return {t('loading')}
;
+ }
+
+ if (!canView) {
+ return (
+
+
{t('noPermissionTitle')}
+
{t('noPermissionBody')}
+
+ );
+ }
+
+ return (
+
+
+
+
+ {loading && tasks.length === 0 ? (
+ {t('loading')}
+ ) : tasks.length === 0 ? (
+
+ {isOwner ? t('emptyListOwner') : t('emptyList')}
+
+ ) : (
+
+ {tasks.map((task) => {
+ const statusEditable =
+ canEdit && (isOwner || task.assigneeUserId === user?.id);
+
+ return (
+
+
+
+ {task.stepOrder}. {task.stepLabel}
+
+
+ {t('fromClinic', { name: task.clinic.name })} ·{' '}
+ {formatPatientName(task.patient)} · {t('toothLabel', { tooth: task.tooth })}
+
+
+ {t('taskDate', { date: formatTaskDate(sortDateForTask(task)) })}
+ {isOwner && (
+ <>
+ ·
+
+ {task.assignee
+ ? t('assignedTo', { name: task.assignee.name })
+ : t('unassigned')}
+
+ >
+ )}
+
+
+
+
+ {statusEditable ? (
+
+ void handleStatusUpdate(task.id, e.target.value as LabTaskStatus)
+ }
+ className={`${FORM_SELECT_CLASS} w-full max-w-[132px]`}
+ >
+ {statusOptions.map((opt) => (
+
+ {opt.label}
+
+ ))}
+
+ ) : (
+
+ {statusOptions.find((opt) => opt.value === task.status)?.label ??
+ task.status}
+
+ )}
+
+
+
+
+ {t('priorityLabel', { n: task.priority })}
+
+
+
+
+ );
+ })}
+
+ )}
+
+
+ {pagination.totalPages > 1 && (
+
+
+ {t('pageSummary', {
+ page: pagination.page,
+ totalPages: pagination.totalPages,
+ total: pagination.total,
+ })}
+
+
+ setPage((p) => Math.max(1, p - 1))}
+ >
+ ←
+
+ = pagination.totalPages || loading}
+ onClick={() => setPage((p) => p + 1)}
+ >
+ →
+
+
+
+ )}
+
+
+
+ );
+}
diff --git a/frontend/src/components/shared/permissions.ts b/frontend/src/components/shared/permissions.ts
index c35867a..aa0f649 100644
--- a/frontend/src/components/shared/permissions.ts
+++ b/frontend/src/components/shared/permissions.ts
@@ -16,6 +16,7 @@ export const DASHBOARD_ROUTES: DashboardRouteConfig[] = [
{ 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: '/tasks', permission: 'TAB_TASKS_READ', orgTypes: ['LAB'] },
{ prefix: '/billing', permission: 'TAB_BILLING_READ', orgTypes: ['CLINIC', 'LAB'] },
{ prefix: '/reports', permission: 'TAB_REPORTS_READ', orgTypes: ['CLINIC', 'LAB'] },
];
@@ -71,6 +72,14 @@ export function canAccessDashboardRoute(org: Organization | null, pathname: stri
return canAccessAppointmentsSection(org);
}
+ if (route.prefix === '/cases') {
+ return canViewCases(org);
+ }
+
+ if (route.prefix === '/tasks') {
+ return canViewTasks(org);
+ }
+
return hasPermission(org, route.permission);
}
@@ -84,6 +93,14 @@ export function firstAccessibleDashboardPath(org: Organization | null): string {
if (canAccessAppointmentsSection(org)) return route.prefix;
continue;
}
+ if (route.prefix === '/cases') {
+ if (canViewCases(org)) return route.prefix;
+ continue;
+ }
+ if (route.prefix === '/tasks') {
+ if (canViewTasks(org)) return route.prefix;
+ continue;
+ }
if (hasPermission(org, route.permission)) return route.prefix;
}
@@ -175,3 +192,21 @@ export function canEditCases(org: Organization | null): boolean {
if (org.isOwner) return true;
return hasPermission(org, 'TAB_CASES_EDIT');
}
+
+/** Lab task inbox */
+export function canViewTasks(org: Organization | null): boolean {
+ if (!org) return false;
+ if (org.type !== 'LAB') return false;
+ if (org.isOwner) return true;
+ return (
+ hasPermission(org, 'TAB_TASKS_READ') ||
+ hasPermission(org, 'TAB_TASKS_EDIT')
+ );
+}
+
+export function canEditTasks(org: Organization | null): boolean {
+ if (!org) return false;
+ if (org.type !== 'LAB') return false;
+ if (org.isOwner) return true;
+ return hasPermission(org, 'TAB_TASKS_EDIT');
+}
diff --git a/frontend/src/components/staff/staff-permission-form.ts b/frontend/src/components/staff/staff-permission-form.ts
index e4312d7..5f4c04a 100644
--- a/frontend/src/components/staff/staff-permission-form.ts
+++ b/frontend/src/components/staff/staff-permission-form.ts
@@ -13,6 +13,7 @@ export const STAFF_FEATURE_GROUPS = [
{ 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: 'featureTasks', read: 'TAB_TASKS_READ', edit: 'TAB_TASKS_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;
diff --git a/frontend/src/components/ui/shared/Dropdown.tsx b/frontend/src/components/ui/shared/Dropdown.tsx
index 1dc9621..5894f38 100644
--- a/frontend/src/components/ui/shared/Dropdown.tsx
+++ b/frontend/src/components/ui/shared/Dropdown.tsx
@@ -28,18 +28,13 @@ export const Dropdown = forwardRef(
ref={ref}
id={selectId}
className={`
- w-full appearance-none rounded-[var(--radius-md)] border
+ form-select w-full appearance-none rounded-[var(--radius-md)] border
${error ? 'border-red-500' : 'border-border'}
- bg-background-secondary/90 text-text-primary
-
+ bg-background-card text-text-primary
pl-4 pr-14 py-2 text-sm
-
focus:outline-none focus:ring-2 focus:ring-primary/35 focus:border-border-strong
-
disabled:opacity-50 disabled:cursor-not-allowed
-
transition-all duration-200 shadow-[inset_0_1px_0_rgba(255,255,255,0.02)]
-
${className}
`}
{...props}
diff --git a/frontend/src/components/ui/shared/Sidebar.tsx b/frontend/src/components/ui/shared/Sidebar.tsx
index d4e2e68..d5a3799 100644
--- a/frontend/src/components/ui/shared/Sidebar.tsx
+++ b/frontend/src/components/ui/shared/Sidebar.tsx
@@ -12,6 +12,7 @@ import {
FileText,
CreditCard,
Package,
+ ListTodo,
} from 'lucide-react';
import type { OrgTypeName } from '@/components/shared/permissions';
import { useAuth } from '@/lib/hooks/useAuth';
@@ -19,6 +20,7 @@ import { usePendingConnectionsCount } from '@/lib/hooks/usePendingConnectionsCou
import {
canAccessAppointmentsSection,
canViewCases,
+ canViewTasks,
canViewTab,
} from '@/components/shared/permissions';
import {
@@ -55,6 +57,7 @@ function Sidebar() {
},
{ 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('tasks'), path: '/tasks', icon: ListTodo, read: 'TAB_TASKS_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'] },
@@ -75,6 +78,9 @@ function Sidebar() {
if (item.path === '/cases') {
return canViewCases(currentOrganization);
}
+ if (item.path === '/tasks') {
+ return canViewTasks(currentOrganization);
+ }
return canViewTab(currentOrganization, item.read);
}),
[currentOrganization, menu, orgType],
diff --git a/frontend/src/components/ui/shared/formSelectStyles.ts b/frontend/src/components/ui/shared/formSelectStyles.ts
new file mode 100644
index 0000000..7f3bb51
--- /dev/null
+++ b/frontend/src/components/ui/shared/formSelectStyles.ts
@@ -0,0 +1,3 @@
+/** Shared native select styling — readable in light and dark themes */
+export const FORM_SELECT_CLASS =
+ 'form-select rounded border border-border bg-background-card text-text-primary px-2 py-1 text-sm disabled:opacity-60 focus:outline-none focus:ring-2 focus:ring-primary/35';
diff --git a/frontend/src/lib/api/cases.ts b/frontend/src/lib/api/cases.ts
index da7496b..2290edf 100644
--- a/frontend/src/lib/api/cases.ts
+++ b/frontend/src/lib/api/cases.ts
@@ -34,7 +34,7 @@ export const casesApi = {
updateTask: async (
caseId: string,
taskId: string,
- payload: { assigneeUserId?: string | null; status?: LabCaseTask['status'] },
+ payload: { assigneeUserId?: string | null; priority?: number },
): Promise<{ success: boolean; data: LabCaseTask }> => {
const response = await apiClient.patch(`/cases/${caseId}/tasks/${taskId}`, payload);
return response.data;
diff --git a/frontend/src/lib/api/tasks.ts b/frontend/src/lib/api/tasks.ts
new file mode 100644
index 0000000..d06875a
--- /dev/null
+++ b/frontend/src/lib/api/tasks.ts
@@ -0,0 +1,20 @@
+import { apiClient } from './client';
+import type { LabTaskListItem, LabTaskStatus, PaginatedLabTasks } from '@/types/cases';
+
+export const tasksApi = {
+ list: async (params: { page?: number; limit?: number } = {}): Promise<{
+ success: boolean;
+ data: PaginatedLabTasks;
+ }> => {
+ const response = await apiClient.get('/tasks', { params });
+ return response.data;
+ },
+
+ updateStatus: async (
+ taskId: string,
+ status: LabTaskStatus,
+ ): Promise<{ success: boolean; data: LabTaskListItem }> => {
+ const response = await apiClient.patch(`/tasks/${taskId}`, { status });
+ return response.data;
+ },
+};
diff --git a/frontend/src/styles/globals.css b/frontend/src/styles/globals.css
index ccb02d4..3e7998b 100644
--- a/frontend/src/styles/globals.css
+++ b/frontend/src/styles/globals.css
@@ -247,6 +247,24 @@ body {
font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
}
+select.form-select,
+select {
+ color: var(--color-text-primary);
+ background-color: var(--color-background-card);
+}
+
+select option {
+ color: var(--color-text-primary);
+ background-color: var(--color-background-secondary);
+}
+
+:root[data-theme='dark'] select.form-select,
+:root[data-theme='dark'] select,
+:root:not([data-theme='light']) select.form-select,
+:root:not([data-theme='light']) select {
+ color-scheme: dark;
+}
+
.surface-card {
background: color-mix(in srgb, var(--color-card-background) 92%, transparent);
border: 1px solid var(--color-card-border);
diff --git a/frontend/src/types/cases.ts b/frontend/src/types/cases.ts
index bd06ded..316d664 100644
--- a/frontend/src/types/cases.ts
+++ b/frontend/src/types/cases.ts
@@ -21,6 +21,9 @@ export interface LabCaseTask {
stepOrder: number;
stepLabel: string;
status: LabTaskStatus;
+ priority: number;
+ assignedAt: string | null;
+ createdAt: string;
assigneeUserId: string | null;
assignee: { id: string; name: string; email: string } | null;
}
@@ -91,3 +94,30 @@ export interface PaginatedLabCases {
totalPages: number;
};
}
+
+export interface LabTaskListItem {
+ id: string;
+ labCaseId: string;
+ tooth: string;
+ treatmentType: string;
+ stepOrder: number;
+ stepLabel: string;
+ status: LabTaskStatus;
+ priority: number;
+ assignedAt: string | null;
+ createdAt: string;
+ assigneeUserId: string | null;
+ assignee: { id: string; name: string; email: string } | null;
+ clinic: { id: string; name: string };
+ patient: { id: string; firstName: string; lastName: string };
+}
+
+export interface PaginatedLabTasks {
+ items: LabTaskListItem[];
+ pagination: {
+ page: number;
+ limit: number;
+ total: number;
+ totalPages: number;
+ };
+}
--
2.53.0.windows.1
From 3e81c110a3682262b69f7451e353190c26c35460 Mon Sep 17 00:00:00 2001
From: Admin
Date: Sun, 28 Jun 2026 23:19:33 +0330
Subject: [PATCH 11/17] improvement: added a history action button to connected
orgs row inordr to see a brief report of the relevant cases between two orgs
and the status of each related task.
---
backend/src/modules/cases/cases.module.ts | 3 +
backend/src/modules/cases/cases.service.ts | 80 ++++
.../organization/organization.controller.ts | 35 ++
.../organization/organization.module.ts | 2 +
.../organization/organization.service.ts | 117 ++++-
frontend/messages/en.json | 11 +-
frontend/messages/fa.json | 11 +-
frontend/messages/nl.json | 11 +-
.../(dashboard)/organizations/page.tsx | 46 +-
.../ConnectionCaseHistoryContent.tsx | 407 ++++++++++++++++++
frontend/src/lib/api/organization.ts | 31 ++
11 files changed, 739 insertions(+), 15 deletions(-)
create mode 100644 frontend/src/components/ui/organizations/ConnectionCaseHistoryContent.tsx
diff --git a/backend/src/modules/cases/cases.module.ts b/backend/src/modules/cases/cases.module.ts
index 6957773..6bd4a71 100644
--- a/backend/src/modules/cases/cases.module.ts
+++ b/backend/src/modules/cases/cases.module.ts
@@ -1,11 +1,14 @@
import { Module } from '@nestjs/common';
import { PrismaService } from '../../../prisma/prisma.service';
import { LabOrgGuard } from '../../common/guards/lab-org.guard';
+import { TreatmentCatalogModule } from '../treatment-catalog/treatment-catalog.module';
import { CasesController } from './cases.controller';
import { CasesService } from './cases.service';
@Module({
+ imports: [TreatmentCatalogModule],
controllers: [CasesController],
providers: [CasesService, PrismaService, LabOrgGuard],
+ exports: [CasesService],
})
export class CasesModule {}
diff --git a/backend/src/modules/cases/cases.service.ts b/backend/src/modules/cases/cases.service.ts
index 940ccc9..401e4d9 100644
--- a/backend/src/modules/cases/cases.service.ts
+++ b/backend/src/modules/cases/cases.service.ts
@@ -156,6 +156,86 @@ export class CasesService {
};
}
+ /** Cases exchanged between one clinic and one lab (Organizations connection history). */
+ async listBetweenOrganizations(
+ clinicOrganizationId: string,
+ labOrganizationId: string,
+ query: ListLabCasesDto,
+ ) {
+ if (query.treatmentType) {
+ this.treatmentCatalog.assertKnownTreatmentType(query.treatmentType);
+ }
+
+ const page = query.page ?? 1;
+ const limit = Math.min(Math.max(query.limit ?? 20, 1), 100);
+ const skip = (page - 1) * limit;
+
+ const where: Prisma.LabCaseWhereInput = {
+ ...this.buildListWhere(labOrganizationId, query),
+ treatment: { organizationId: clinicOrganizationId },
+ sends: { some: { organizationId: labOrganizationId } },
+ };
+
+ const [items, total] = await Promise.all([
+ this.prisma.labCase.findMany({
+ where,
+ include: {
+ treatment: {
+ include: {
+ organization: { select: { id: true, name: true } },
+ patient: { select: { id: true, firstName: true, lastName: true, mobile: true } },
+ },
+ },
+ details: {
+ include: {
+ detail: { select: { treatmentType: true } },
+ },
+ },
+ tasks: { select: { id: true, status: true } },
+ },
+ orderBy: [{ sentAt: 'desc' }],
+ skip,
+ take: limit,
+ }),
+ this.prisma.labCase.count({ where }),
+ ]);
+
+ return {
+ success: true,
+ data: {
+ items: items.map((lc) => this.mapLabCaseListItem(lc)),
+ pagination: {
+ page,
+ limit,
+ total,
+ totalPages: Math.max(1, Math.ceil(total / limit)),
+ },
+ },
+ };
+ }
+
+ async getOneBetweenOrganizations(
+ labCaseId: string,
+ clinicOrganizationId: string,
+ labOrganizationId: string,
+ ) {
+ const labCase = await this.prisma.labCase.findFirst({
+ where: {
+ id: labCaseId,
+ sentAt: { not: null },
+ treatment: { organizationId: clinicOrganizationId },
+ sends: { some: { organizationId: labOrganizationId } },
+ },
+ include: labCaseListInclude,
+ });
+
+ if (!labCase) {
+ throw new NotFoundException('Case not found');
+ }
+
+ return { success: true, data: this.mapLabCaseDetail(labCase) };
+ }
+
async getOne(labCaseId: string, labOrganizationId: string, actorUserId: string) {
await this.assertCanReadCases(actorUserId, labOrganizationId);
diff --git a/backend/src/modules/organization/organization.controller.ts b/backend/src/modules/organization/organization.controller.ts
index 7f0fc68..90db4e4 100644
--- a/backend/src/modules/organization/organization.controller.ts
+++ b/backend/src/modules/organization/organization.controller.ts
@@ -18,6 +18,7 @@ import { InviteOrganizationDto } from './dto/invite-organization.dto';
import { PreviewOrganizationInviteDto } from './dto/preview-organization-invite.dto';
import { RespondConnectionRequestDto } from './dto/respond-connection-request.dto';
import { OrganizationService } from './organization.service';
+import { ListLabCasesDto } from '../cases/dto/cases.dto';
/**
* Counterpart orgs (clinic↔lab).
@@ -121,6 +122,40 @@ export class OrganizationController {
return this.organizationService.deleteConnection(req.user.id, organizationId, connectionId);
}
+ @Get('connections/:connectionId/cases')
+ @UseGuards(JwtAuthGuard)
+ @ApiOperation({ summary: 'List cases exchanged with a connected organization' })
+ listConnectionCases(
+ @Req() req: { user: { id: string; organizationId?: string } },
+ @Param('connectionId') connectionId: string,
+ @Query() query: ListLabCasesDto,
+ ) {
+ const organizationId = this.organizationService.getOrganizationIdFromUser(req.user);
+ return this.organizationService.listConnectionCases(
+ req.user.id,
+ organizationId,
+ connectionId,
+ query,
+ );
+ }
+
+ @Get('connections/:connectionId/cases/:caseId')
+ @UseGuards(JwtAuthGuard)
+ @ApiOperation({ summary: 'Get one case exchanged with a connected organization' })
+ getConnectionCase(
+ @Req() req: { user: { id: string; organizationId?: string } },
+ @Param('connectionId') connectionId: string,
+ @Param('caseId') caseId: string,
+ ) {
+ const organizationId = this.organizationService.getOrganizationIdFromUser(req.user);
+ return this.organizationService.getConnectionCase(
+ req.user.id,
+ organizationId,
+ connectionId,
+ caseId,
+ );
+ }
+
@Post('invitations/:invitationId/link')
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: 'Get a shareable invite link for a pending invitation' })
diff --git a/backend/src/modules/organization/organization.module.ts b/backend/src/modules/organization/organization.module.ts
index d36aef2..b8626c4 100644
--- a/backend/src/modules/organization/organization.module.ts
+++ b/backend/src/modules/organization/organization.module.ts
@@ -1,9 +1,11 @@
import { Module } from '@nestjs/common';
import { PrismaService } from '../../../prisma/prisma.service';
+import { CasesModule } from '../cases/cases.module';
import { OrganizationController } from './organization.controller';
import { OrganizationService } from './organization.service';
@Module({
+ imports: [CasesModule],
controllers: [OrganizationController],
providers: [OrganizationService, PrismaService],
})
diff --git a/backend/src/modules/organization/organization.service.ts b/backend/src/modules/organization/organization.service.ts
index 6945d09..fc13621 100644
--- a/backend/src/modules/organization/organization.service.ts
+++ b/backend/src/modules/organization/organization.service.ts
@@ -9,6 +9,8 @@ import { LinkStatus } from '@prisma/client';
import * as bcrypt from 'bcrypt';
import { createHash, randomBytes } from 'crypto';
import { PrismaService } from '../../../prisma/prisma.service';
+import { ListLabCasesDto } from '../cases/dto/cases.dto';
+import { CasesService } from '../cases/cases.service';
import { AcceptOrganizationInviteDto } from './dto/accept-organization-invite.dto';
import { CreateConnectionRequestDto } from './dto/create-connection-request.dto';
import { InviteOrganizationDto } from './dto/invite-organization.dto';
@@ -28,7 +30,10 @@ import { RespondConnectionRequestDto } from './dto/respond-connection-request.dt
*/
@Injectable()
export class OrganizationService {
- constructor(private readonly prisma: PrismaService) {}
+ constructor(
+ private readonly prisma: PrismaService,
+ private readonly casesService: CasesService,
+ ) {}
getOrganizationIdFromUser(user: { organizationId?: string }) {
if (!user?.organizationId) {
@@ -340,6 +345,64 @@ export class OrganizationService {
};
}
+ async listConnectionCases(
+ userId: string,
+ organizationId: string,
+ connectionId: string,
+ query: ListLabCasesDto,
+ ) {
+ const actor = await this.getActorMembership(userId, organizationId);
+ if (!actor || !this.canEditOrganizations(actor)) {
+ throw new ForbiddenException('You do not have permission to manage organizations');
+ }
+
+ const { clinicOrganizationId, labOrganizationId, counterpart } =
+ await this.resolveActiveConnectionParties(connectionId, organizationId, actor);
+
+ const result = await this.casesService.listBetweenOrganizations(
+ clinicOrganizationId,
+ labOrganizationId,
+ query,
+ );
+
+ return {
+ ...result,
+ data: {
+ ...result.data,
+ counterpart,
+ },
+ };
+ }
+
+ async getConnectionCase(
+ userId: string,
+ organizationId: string,
+ connectionId: string,
+ caseId: string,
+ ) {
+ const actor = await this.getActorMembership(userId, organizationId);
+ if (!actor || !this.canEditOrganizations(actor)) {
+ throw new ForbiddenException('You do not have permission to manage organizations');
+ }
+
+ const { clinicOrganizationId, labOrganizationId, counterpart } =
+ await this.resolveActiveConnectionParties(connectionId, organizationId, actor);
+
+ const result = await this.casesService.getOneBetweenOrganizations(
+ caseId,
+ clinicOrganizationId,
+ labOrganizationId,
+ );
+
+ return {
+ ...result,
+ data: {
+ ...result.data,
+ counterpart,
+ },
+ };
+ }
+
/** Re-issue a shareable URL for a pending invitation (rotates token; previous URL stops working). */
async getInvitationLink(userId: string, organizationId: string, invitationId: string) {
const actor = await this.getActorMembership(userId, organizationId);
@@ -640,6 +703,58 @@ export class OrganizationService {
};
}
+ private async resolveActiveConnectionParties(
+ connectionId: string,
+ organizationId: string,
+ actor: {
+ organization: { type: { name: string } };
+ },
+ ) {
+ const link = await this.prisma.organizationLink.findFirst({
+ where: {
+ id: connectionId,
+ status: LinkStatus.ACTIVE,
+ OR: [{ organizationAId: organizationId }, { organizationBId: organizationId }],
+ },
+ include: {
+ organizationA: { select: { id: true, name: true, type: true } },
+ organizationB: { select: { id: true, name: true, type: true } },
+ },
+ });
+
+ if (!link) {
+ throw new NotFoundException('Connected organization not found');
+ }
+
+ const counterpart =
+ link.organizationAId === organizationId ? link.organizationB : link.organizationA;
+
+ const orgType = actor.organization.type.name;
+ if (orgType === 'CLINIC') {
+ if (counterpart.type.name !== 'LAB') {
+ throw new BadRequestException('Counterpart organization is not a lab');
+ }
+ return {
+ clinicOrganizationId: organizationId,
+ labOrganizationId: counterpart.id,
+ counterpart: { id: counterpart.id, name: counterpart.name },
+ };
+ }
+
+ if (orgType === 'LAB') {
+ if (counterpart.type.name !== 'CLINIC') {
+ throw new BadRequestException('Counterpart organization is not a clinic');
+ }
+ return {
+ clinicOrganizationId: counterpart.id,
+ labOrganizationId: organizationId,
+ counterpart: { id: counterpart.id, name: counterpart.name },
+ };
+ }
+
+ throw new BadRequestException('Unknown organization type');
+ }
+
private async getActorMembership(userId: string, organizationId: string) {
return this.prisma.membership.findFirst({
where: { userId, organizationId },
diff --git a/frontend/messages/en.json b/frontend/messages/en.json
index e482d97..a8fcaab 100644
--- a/frontend/messages/en.json
+++ b/frontend/messages/en.json
@@ -606,7 +606,16 @@
"continueArrow": "Continue →",
"planLabel": "Plan: {name} • {maxUsers} users",
"counterpartClinic": "Clinic",
- "counterpartLab": "Lab"
+ "counterpartLab": "Lab",
+ "viewCaseHistory": "View case history",
+ "caseHistoryBackToConnections": "← Back to connections",
+ "caseHistoryTitle": "Case history with {name}",
+ "caseHistorySubtitleClinic": "Cases you sent to this lab, including lab workflow status for each step.",
+ "caseHistorySubtitleLab": "Cases received from this clinic, including task status for each step.",
+ "caseHistoryEmpty": "No cases exchanged with this organization yet.",
+ "caseHistorySentToLab": "Sent to {name}",
+ "caseHistoryErrorLoadList": "Failed to load case history.",
+ "caseHistoryErrorLoadDetail": "Failed to load case details."
},
"settings": {
"accountTitle": "Account",
diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json
index 39012cc..36e2021 100644
--- a/frontend/messages/fa.json
+++ b/frontend/messages/fa.json
@@ -606,7 +606,16 @@
"continueArrow": "ادامه →",
"planLabel": "طرح: {name} • {maxUsers} کاربر",
"counterpartClinic": "کلینیک",
- "counterpartLab": "لابراتوار"
+ "counterpartLab": "لابراتوار",
+ "viewCaseHistory": "مشاهده تاریخچه پروندهها",
+ "caseHistoryBackToConnections": "← بازگشت به اتصالات",
+ "caseHistoryTitle": "تاریخچه پرونده با {name}",
+ "caseHistorySubtitleClinic": "پروندههایی که به این لابراتوار ارسال کردهاید، شامل وضعیت گردش کار لابراتوار برای هر مرحله.",
+ "caseHistorySubtitleLab": "پروندههای دریافتی از این کلینیک، شامل وضعیت وظایف برای هر مرحله.",
+ "caseHistoryEmpty": "هنوز پروندهای با این سازمان رد و بدل نشده است.",
+ "caseHistorySentToLab": "ارسال شده به {name}",
+ "caseHistoryErrorLoadList": "بارگذاری تاریخچه پرونده ناموفق بود.",
+ "caseHistoryErrorLoadDetail": "بارگذاری جزئیات پرونده ناموفق بود."
},
"settings": {
"accountTitle": "حساب کاربری",
diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json
index e878c98..0097a5a 100644
--- a/frontend/messages/nl.json
+++ b/frontend/messages/nl.json
@@ -606,7 +606,16 @@
"continueArrow": "Doorgaan →",
"planLabel": "Plan: {name} • {maxUsers} gebruikers",
"counterpartClinic": "Kliniek",
- "counterpartLab": "Laboratorium"
+ "counterpartLab": "Laboratorium",
+ "viewCaseHistory": "Casusgeschiedenis bekijken",
+ "caseHistoryBackToConnections": "← Terug naar verbindingen",
+ "caseHistoryTitle": "Casusgeschiedenis met {name}",
+ "caseHistorySubtitleClinic": "Cases die u naar dit lab hebt gestuurd, inclusief lab-workflowstatus per stap.",
+ "caseHistorySubtitleLab": "Cases ontvangen van deze kliniek, inclusief taakstatus per stap.",
+ "caseHistoryEmpty": "Nog geen cases uitgewisseld met deze organisatie.",
+ "caseHistorySentToLab": "Verzonden naar {name}",
+ "caseHistoryErrorLoadList": "Casusgeschiedenis laden mislukt.",
+ "caseHistoryErrorLoadDetail": "Casusdetails laden mislukt."
},
"settings": {
"accountTitle": "Account",
diff --git a/frontend/src/app/[locale]/(dashboard)/organizations/page.tsx b/frontend/src/app/[locale]/(dashboard)/organizations/page.tsx
index c0ffcfc..cc1da47 100644
--- a/frontend/src/app/[locale]/(dashboard)/organizations/page.tsx
+++ b/frontend/src/app/[locale]/(dashboard)/organizations/page.tsx
@@ -3,7 +3,7 @@
import { useCallback, useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { useToast } from '@/lib/hooks/useToast';
-import { Check, Trash2, UserPlus, X } from 'lucide-react';
+import { Check, History, Trash2, UserPlus, X } from 'lucide-react';
import { useAuth } from '@/lib/hooks/useAuth';
import { notifyPendingConnectionsChanged } from '@/lib/hooks/usePendingConnectionsCount';
import { useOrganizationInviteLinkCopy } from '@/lib/hooks/useOrganizationInviteLinkCopy';
@@ -16,6 +16,7 @@ import {
import { invitationTargetFromConnectionRow } from '@/components/invitations/organizationInviteLinks';
import { CopyInvitationLinkButton } from '@/components/ui/organizations/CopyInvitationLinkButton';
import { InvitationHistoryDialog } from '@/components/ui/organizations/InvitationHistoryDialog';
+import { ConnectionCaseHistoryContent } from '@/components/ui/organizations/ConnectionCaseHistoryContent';
import { Button } from '@/components/ui/shared/Button';
import { Badge, organizationConnectionStatusVariant } from '@/components/ui/shared/Badge';
import { Input } from '@/components/ui/shared/Input';
@@ -90,6 +91,9 @@ export default function OrganizationsPage() {
const [historyOpen, setHistoryOpen] = useState(false);
const [historyLoading, setHistoryLoading] = useState(false);
const [historyItems, setHistoryItems] = useState([]);
+ const [caseHistoryConnection, setCaseHistoryConnection] = useState(
+ null,
+ );
const {
copiedId,
@@ -289,6 +293,15 @@ export default function OrganizationsPage() {
return {t('loadingOrganization')}
;
}
+ if (caseHistoryConnection) {
+ return (
+ setCaseHistoryConnection(null)}
+ />
+ );
+ }
+
return (
@@ -426,16 +439,27 @@ export default function OrganizationsPage() {
>
)}
{row.status === 'ACTIVE' && (
- void deleteConnection(row.id)}
- aria-label={t('removeConnection')}
- title={t('removeConnection')}
- >
-
-
+ <>
+ setCaseHistoryConnection(row)}
+ aria-label={t('viewCaseHistory')}
+ title={t('viewCaseHistory')}
+ >
+
+
+ void deleteConnection(row.id)}
+ aria-label={t('removeConnection')}
+ title={t('removeConnection')}
+ >
+
+
+ >
)}
diff --git a/frontend/src/components/ui/organizations/ConnectionCaseHistoryContent.tsx b/frontend/src/components/ui/organizations/ConnectionCaseHistoryContent.tsx
new file mode 100644
index 0000000..b95dc33
--- /dev/null
+++ b/frontend/src/components/ui/organizations/ConnectionCaseHistoryContent.tsx
@@ -0,0 +1,407 @@
+'use client';
+
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useTranslations } from 'next-intl';
+import { formatApiErrorMessage } from '@/components/shared/formatApiError';
+import { useAuth } from '@/lib/hooks/useAuth';
+import { useToast } from '@/lib/hooks/useToast';
+import { organizationApi } from '@/lib/api/organization';
+import { Badge, type BadgeVariant } from '@/components/ui/shared/Badge';
+import { Button } from '@/components/ui/shared/Button';
+import { SearchBar } from '@/components/ui/shared/SearchBar';
+import { ToastStack } from '@/components/ui/shared/Toast';
+import type { CounterpartItemDto } from '@/lib/api/organization';
+import type { LabCaseDetail, LabCaseListItem, LabTaskStatus } from '@/types/cases';
+
+const TREATMENT_TYPE_KEYS = {
+ consultation: 'typeConsultation',
+ filling: 'typeFilling',
+ endo: 'typeEndo',
+ visit: 'typeVisit',
+ hygiene: 'typeHygiene',
+} as const;
+
+const PAGE_SIZE = 20;
+
+function taskStatusVariant(status: LabTaskStatus): BadgeVariant {
+ switch (status) {
+ case 'COMPLETED':
+ return 'success';
+ case 'IN_PROGRESS':
+ return 'default';
+ default:
+ return 'warning';
+ }
+}
+
+function formatPatientName(patient: { firstName: string; lastName: string }) {
+ return `${patient.firstName} ${patient.lastName}`.trim();
+}
+
+function formatDateTime(value: string | null, locale: string) {
+ if (!value) return '—';
+ return new Intl.DateTimeFormat(locale, {
+ dateStyle: 'medium',
+ timeStyle: 'short',
+ }).format(new Date(value));
+}
+
+function TaskProgressBar({ completed, total }: { completed: number; total: number }) {
+ const pct = total > 0 ? Math.round((completed / total) * 100) : 0;
+
+ return (
+
+
+
+ {completed}/{total}
+
+ {pct}%
+
+
+
+ );
+}
+
+interface ConnectionCaseHistoryContentProps {
+ connection: CounterpartItemDto;
+ onBack: () => void;
+}
+
+export function ConnectionCaseHistoryContent({
+ connection,
+ onBack,
+}: ConnectionCaseHistoryContentProps) {
+ const t = useTranslations('organizations');
+ const tCases = useTranslations('cases');
+ const tTreatment = useTranslations('treatment');
+ const tCommon = useTranslations('common');
+ const { currentOrganization, user } = useAuth();
+ const { showError, setError, messages: toastMessages } = useToast();
+
+ const [search, setSearch] = useState('');
+ const [page, setPage] = useState(1);
+ const [cases, setCases] = useState
([]);
+ const [pagination, setPagination] = useState({
+ page: 1,
+ limit: PAGE_SIZE,
+ total: 0,
+ totalPages: 1,
+ });
+ const [selectedCaseId, setSelectedCaseId] = useState(null);
+ const [selectedCase, setSelectedCase] = useState(null);
+ const [loadingList, setLoadingList] = useState(false);
+ const [loadingDetail, setLoadingDetail] = useState(false);
+
+ const locale = user?.language ?? 'en';
+ const isClinic = currentOrganization?.type === 'CLINIC';
+
+ const tRef = useRef(t);
+ tRef.current = t;
+
+ const treatmentLabel = useCallback(
+ (type: string) => {
+ const key = TREATMENT_TYPE_KEYS[type as keyof typeof TREATMENT_TYPE_KEYS];
+ return key ? tTreatment(key) : type;
+ },
+ [tTreatment],
+ );
+
+ const statusOptions: { value: LabTaskStatus; label: string }[] = useMemo(
+ () => [
+ { value: 'PENDING', label: tCases('statusPending') },
+ { value: 'IN_PROGRESS', label: tCases('statusInProgress') },
+ { value: 'COMPLETED', label: tCases('statusCompleted') },
+ ],
+ [tCases],
+ );
+
+ useEffect(() => {
+ let cancelled = false;
+
+ const timeout = setTimeout(() => {
+ void (async () => {
+ setLoadingList(true);
+ setError('');
+ try {
+ const response = await organizationApi.listConnectionCases(connection.id, {
+ q: search.trim() || undefined,
+ page,
+ limit: PAGE_SIZE,
+ });
+ if (cancelled) return;
+ setCases(response.data.items);
+ setPagination(response.data.pagination);
+ } catch (error: unknown) {
+ if (cancelled) return;
+ showError(formatApiErrorMessage(error, tRef.current('caseHistoryErrorLoadList')));
+ } finally {
+ if (!cancelled) setLoadingList(false);
+ }
+ })();
+ }, search ? 300 : 0);
+
+ return () => {
+ cancelled = true;
+ clearTimeout(timeout);
+ };
+ }, [search, page, connection.id, showError, setError]);
+
+ useEffect(() => {
+ if (!selectedCaseId) {
+ setSelectedCase(null);
+ return;
+ }
+
+ let cancelled = false;
+
+ void (async () => {
+ setLoadingDetail(true);
+ setError('');
+ try {
+ const response = await organizationApi.getConnectionCase(connection.id, selectedCaseId);
+ if (cancelled) return;
+ setSelectedCase(response.data);
+ } catch (error: unknown) {
+ if (cancelled) return;
+ showError(formatApiErrorMessage(error, tRef.current('caseHistoryErrorLoadDetail')));
+ setSelectedCase(null);
+ } finally {
+ if (!cancelled) setLoadingDetail(false);
+ }
+ })();
+
+ return () => {
+ cancelled = true;
+ };
+ }, [selectedCaseId, connection.id, showError, setError]);
+
+ return (
+
+
+
+ {t('caseHistoryBackToConnections')}
+
+
+
+
+
+ {t('caseHistoryTitle', { name: connection.organizationName })}
+
+
+ {isClinic ? t('caseHistorySubtitleClinic') : t('caseHistorySubtitleLab')}
+
+
+
+
+
+
+
+ {
+ setSearch(value);
+ setPage(1);
+ }}
+ placeholder={tCases('searchPlaceholder')}
+ />
+
+
+ {loadingList ? (
+
{tCommon('loading')}
+ ) : cases.length === 0 ? (
+
{t('caseHistoryEmpty')}
+ ) : (
+
+ {cases.map((item) => {
+ const isActive = item.id === selectedCaseId;
+
+ return (
+
+ setSelectedCaseId(item.id)}
+ className={`w-full rounded-md border px-3 py-2.5 text-left transition-colors ${
+ isActive
+ ? 'border-primary bg-primary/5'
+ : 'border-border hover:border-primary/40'
+ }`}
+ >
+
+ {formatPatientName(item.patient)}
+
+ {item.patient.mobile}
+ {!isClinic ? (
+ {item.clinic.name}
+ ) : null}
+
+ {formatDateTime(item.sentAt, locale)}
+
+
+ {item.treatmentTypes.map(treatmentLabel).join(', ')}
+
+
+
+
+
+
+ );
+ })}
+
+ )}
+
+
+ {pagination.totalPages > 1 ? (
+
+ setPage((p) => Math.max(1, p - 1))}
+ >
+ {tCases('prevPage')}
+
+
+ {tCases('pageSummary', {
+ page: pagination.page,
+ totalPages: pagination.totalPages,
+ total: pagination.total,
+ })}
+
+ = pagination.totalPages || loadingList}
+ onClick={() => setPage((p) => p + 1)}
+ >
+ {tCases('nextPage')}
+
+
+ ) : null}
+
+
+
+ {!selectedCaseId ? (
+ {tCases('selectCaseHint')}
+ ) : loadingDetail || !selectedCase ? (
+ {tCommon('loading')}
+ ) : (
+
+
+
+ {formatPatientName(selectedCase.patient)}
+
+
+ {tCases('patientMobile')}: {selectedCase.patient.mobile}
+
+ {!isClinic ? (
+
+ {tCases('fromClinic', { name: selectedCase.clinic.name })}
+
+ ) : (
+
+ {t('caseHistorySentToLab', { name: connection.organizationName })}
+
+ )}
+
+ {tCases('sentAt', { date: formatDateTime(selectedCase.sentAt, locale) })}
+
+
+
+ {tCases('taskProgressLabel', {
+ completed: selectedCase.taskProgress.completed,
+ total: selectedCase.taskProgress.total,
+ })}
+
+
+
+ {selectedCase.labComment ? (
+
+ {tCases('labComment')}: {' '}
+ {selectedCase.labComment}
+
+ ) : null}
+
+
+ {selectedCase.details.length > 0 && (
+
+
+ {tCases('treatmentDetails')}
+
+
+
+ )}
+
+
+
{tCases('tasksByTooth')}
+ {selectedCase.tasksByTooth.length === 0 ? (
+
{tCases('noTasks')}
+ ) : (
+ selectedCase.tasksByTooth.map((group) => (
+
+
+ {tCases('toothGroupTitle', {
+ tooth: group.tooth,
+ type: treatmentLabel(group.treatmentType),
+ })}
+
+
+ {group.tasks.map((task) => (
+
+
+ {task.stepOrder}. {task.stepLabel}
+
+
+ {statusOptions.find((opt) => opt.value === task.status)?.label ??
+ task.status}
+
+
+ ))}
+
+
+ ))
+ )}
+
+
+ )}
+
+
+
+ );
+}
diff --git a/frontend/src/lib/api/organization.ts b/frontend/src/lib/api/organization.ts
index 0e54cd8..b9a4f74 100644
--- a/frontend/src/lib/api/organization.ts
+++ b/frontend/src/lib/api/organization.ts
@@ -1,4 +1,9 @@
import { apiClient } from './client';
+import type {
+ LabCaseDetail,
+ ListLabCasesParams,
+ PaginatedLabCases,
+} from '@/types/cases';
export interface CounterpartSearchResultDto {
id: string;
@@ -130,4 +135,30 @@ export const organizationApi = {
const response = await apiClient.post('/organizations/invitations/accept', body);
return response.data;
},
+
+ listConnectionCases: async (
+ connectionId: string,
+ params: ListLabCasesParams = {},
+ ): Promise<{
+ success: boolean;
+ data: PaginatedLabCases & { counterpart: { id: string; name: string } };
+ }> => {
+ const response = await apiClient.get(`/organizations/connections/${connectionId}/cases`, {
+ params,
+ });
+ return response.data;
+ },
+
+ getConnectionCase: async (
+ connectionId: string,
+ caseId: string,
+ ): Promise<{
+ success: boolean;
+ data: LabCaseDetail & { counterpart: { id: string; name: string } };
+ }> => {
+ const response = await apiClient.get(
+ `/organizations/connections/${connectionId}/cases/${caseId}`,
+ );
+ return response.data;
+ },
};
--
2.53.0.windows.1
From 667b08ed0c3a1519534e9a36c0e3b7a514a4bc59 Mon Sep 17 00:00:00 2001
From: Admin
Date: Mon, 6 Jul 2026 20:40:19 +0330
Subject: [PATCH 12/17] TreatmentType and ProsthesisType database shcema and
data updated. Lab dispatch wired through new data.
---
backend/package.json | 3 +-
backend/prisma/catalog-seed-data.ts | 276 ++++++++++++++++++
.../migration.sql | 95 ++++++
backend/prisma/reset-treatment-data.ts | 41 +++
backend/prisma/schema.prisma | 110 +++++--
backend/prisma/seed.ts | 131 +++++++--
backend/src/app.module.ts | 4 +
backend/src/modules/cases/cases.controller.ts | 4 +-
backend/src/modules/cases/cases.service.ts | 98 +++++--
.../cases/lab-case-task.generator.spec.ts | 154 ++++++++++
.../modules/cases/lab-case-task.generator.ts | 145 +++++----
.../modules/catalog/catalog-label.service.ts | 67 +++++
backend/src/modules/catalog/catalog.module.ts | 10 +
.../organization/organization.controller.ts | 3 +-
.../organization/organization.service.ts | 2 +
.../prosthesis-catalog.controller.ts | 26 ++
.../prosthesis-catalog.module.ts | 11 +
.../prosthesis-catalog.service.ts | 98 +++++++
backend/src/modules/tasks/tasks.controller.ts | 10 +-
backend/src/modules/tasks/tasks.service.ts | 42 ++-
.../treatment-catalog.controller.ts | 12 +-
.../treatment-catalog.service.ts | 49 +++-
.../modules/treatments/dto/treatment.dto.ts | 19 ++
.../lab-case-send.validation.spec.ts | 52 ++++
.../treatments/lab-case-send.validation.ts | 37 +++
.../treatments/treatments.controller.ts | 9 +-
.../modules/treatments/treatments.module.ts | 2 +
.../modules/treatments/treatments.service.ts | 59 +++-
frontend/messages/en.json | 10 +-
frontend/messages/fa.json | 10 +-
frontend/messages/nl.json | 10 +-
.../app/[locale]/(dashboard)/cases/page.tsx | 21 +-
.../app/[locale]/(dashboard)/tasks/page.tsx | 14 +-
.../ConnectionCaseHistoryContent.tsx | 25 +-
.../ui/treatment/AppointmentsStrip.tsx | 18 +-
.../ui/treatment/LabCasesDispatchPanel.tsx | 200 ++++++++++++-
.../ui/treatment/PastTreatmentsPanel.tsx | 4 +
.../treatment/TreatmentDetailSummaryRow.tsx | 9 +-
.../ui/treatment/TreatmentDetailsEditor.tsx | 28 +-
.../treatment/TreatmentHistoryDetailLine.tsx | 9 +-
.../ui/treatment/TreatmentPreviewCard.tsx | 4 +
.../ui/treatment/TreatmentTypeBadge.tsx | 15 +-
.../ui/treatment/TreatmentWorkspace.tsx | 38 ++-
.../ui/treatment/treatmentTypeDisplay.ts | 50 +++-
frontend/src/lib/api/prosthesis-catalog.ts | 13 +
frontend/src/types/cases.ts | 7 +
frontend/src/types/treatment-catalog.ts | 7 +
frontend/src/types/treatment.ts | 27 +-
48 files changed, 1813 insertions(+), 275 deletions(-)
create mode 100644 backend/prisma/catalog-seed-data.ts
create mode 100644 backend/prisma/migrations/20260706120000_prosthesis_catalog/migration.sql
create mode 100644 backend/prisma/reset-treatment-data.ts
create mode 100644 backend/src/modules/cases/lab-case-task.generator.spec.ts
create mode 100644 backend/src/modules/catalog/catalog-label.service.ts
create mode 100644 backend/src/modules/catalog/catalog.module.ts
create mode 100644 backend/src/modules/prosthesis-catalog/prosthesis-catalog.controller.ts
create mode 100644 backend/src/modules/prosthesis-catalog/prosthesis-catalog.module.ts
create mode 100644 backend/src/modules/prosthesis-catalog/prosthesis-catalog.service.ts
create mode 100644 backend/src/modules/treatments/lab-case-send.validation.spec.ts
create mode 100644 backend/src/modules/treatments/lab-case-send.validation.ts
create mode 100644 frontend/src/lib/api/prosthesis-catalog.ts
diff --git a/backend/package.json b/backend/package.json
index fa41833..ed0e131 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -21,7 +21,8 @@
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:deploy": "prisma migrate deploy",
- "prisma:seed": "prisma db seed"
+ "prisma:seed": "prisma db seed",
+ "prisma:reset-treatment": "ts-node prisma/reset-treatment-data.ts"
},
"prisma": {
"seed": "ts-node prisma/seed.ts"
diff --git a/backend/prisma/catalog-seed-data.ts b/backend/prisma/catalog-seed-data.ts
new file mode 100644
index 0000000..bfb7a7e
--- /dev/null
+++ b/backend/prisma/catalog-seed-data.ts
@@ -0,0 +1,276 @@
+import { CatalogEntityKind } from '@prisma/client';
+
+export type CatalogTranslationSeed = {
+ entityKind: CatalogEntityKind;
+ entityCode: string;
+ locale: string;
+ label: string;
+};
+
+export const TREATMENT_TYPES = [
+ { code: 'restoration', labDependent: false, sortOrder: 1 },
+ { code: 'specialized_restoration', labDependent: false, sortOrder: 2 },
+ { code: 'radiography', labDependent: false, sortOrder: 3 },
+ { code: 'endo', labDependent: false, sortOrder: 4 },
+ { code: 'surgery', labDependent: false, sortOrder: 5 },
+ { code: 'prosthesis', labDependent: true, sortOrder: 6 },
+ { code: 'implant', labDependent: false, sortOrder: 7 },
+ { code: 'orthodontics', labDependent: false, sortOrder: 8 },
+ { code: 'perio', labDependent: false, sortOrder: 9 },
+ { code: 'pediatrics', labDependent: false, sortOrder: 10 },
+ { code: 'extraction', labDependent: false, sortOrder: 11 },
+ { code: 'clinic_visit', labDependent: false, sortOrder: 12 },
+] as const;
+
+/** Legacy codes kept for historical rows; hidden from catalog. */
+export const LEGACY_TREATMENT_TYPES = [
+ { code: 'consultation', labDependent: false, sortOrder: 99 },
+ { code: 'filling', labDependent: false, sortOrder: 100 },
+ { code: 'visit', labDependent: false, sortOrder: 101 },
+ { code: 'hygiene', labDependent: false, sortOrder: 102 },
+] as const;
+
+export const LAB_WORKFLOW_STEPS = [
+ { code: 'intraoral_scan', sortOrder: 1 },
+ { code: 'design', sortOrder: 2 },
+ { code: 'milling_dry', sortOrder: 3 },
+ { code: 'milling_wet', sortOrder: 4 },
+ { code: 'printer_resin', sortOrder: 5 },
+ { code: 'printer_metal', sortOrder: 6 },
+ { code: 'sinter', sortOrder: 7 },
+ { code: 'build_up', sortOrder: 8 },
+ { code: 'stain', sortOrder: 9 },
+ { code: 'glaze', sortOrder: 10 },
+ { code: 'polish_prep', sortOrder: 11 },
+ { code: 'packing', sortOrder: 12 },
+ { code: 'shipping', sortOrder: 13 },
+ { code: 'pressing', sortOrder: 14 },
+] as const;
+
+export type ProsthesisTypeSeed = {
+ code: string;
+ sortOrder: number;
+ skipPackingShipping?: boolean;
+ /** Manufacturing steps between design and packing (exclusive of universal scan/design/pack/ship). */
+ manufacturingSteps: readonly string[];
+};
+
+export const PROSTHESIS_TYPES: ProsthesisTypeSeed[] = [
+ {
+ code: 'pfm_crown',
+ sortOrder: 1,
+ manufacturingSteps: ['milling_wet', 'build_up', 'stain', 'glaze', 'polish_prep'],
+ },
+ {
+ code: 'pfz_crown',
+ sortOrder: 2,
+ manufacturingSteps: ['milling_dry', 'sinter', 'build_up', 'stain', 'glaze', 'polish_prep'],
+ },
+ {
+ code: 'monolithic_zirconia',
+ sortOrder: 3,
+ manufacturingSteps: ['milling_dry', 'sinter', 'stain', 'glaze', 'polish_prep'],
+ },
+ {
+ code: 'glass_ceramic_crown',
+ sortOrder: 4,
+ manufacturingSteps: ['milling_wet', 'stain', 'glaze', 'polish_prep'],
+ },
+ {
+ code: 'full_metal_crown',
+ sortOrder: 5,
+ manufacturingSteps: ['milling_wet', 'polish_prep'],
+ },
+ {
+ code: 'temporary_resin_crown',
+ sortOrder: 6,
+ manufacturingSteps: ['milling_wet', 'polish_prep'],
+ },
+ {
+ code: 'pmma',
+ sortOrder: 7,
+ manufacturingSteps: ['milling_dry', 'polish_prep'],
+ },
+ {
+ code: 'peek_crown',
+ sortOrder: 8,
+ manufacturingSteps: ['milling_dry', 'polish_prep'],
+ },
+ {
+ code: 'veneer_zirconia',
+ sortOrder: 9,
+ manufacturingSteps: ['milling_dry', 'sinter', 'build_up', 'stain', 'glaze', 'polish_prep'],
+ },
+ {
+ code: 'veneer_ips_press',
+ sortOrder: 10,
+ manufacturingSteps: [
+ 'printer_resin',
+ 'build_up',
+ 'stain',
+ 'glaze',
+ 'polish_prep',
+ 'pressing',
+ ],
+ },
+ {
+ code: 'veneer_ips_cad',
+ sortOrder: 11,
+ manufacturingSteps: ['milling_wet', 'stain', 'glaze', 'polish_prep'],
+ },
+ {
+ code: 'soft_structure',
+ sortOrder: 12,
+ manufacturingSteps: ['milling_dry', 'sinter'],
+ },
+ {
+ code: 'customized_abutment',
+ sortOrder: 13,
+ manufacturingSteps: ['milling_wet', 'polish_prep'],
+ },
+ {
+ code: 'prefabricated_abutment',
+ sortOrder: 14,
+ manufacturingSteps: ['polish_prep'],
+ },
+ {
+ code: 'ti_base_abutment',
+ sortOrder: 15,
+ manufacturingSteps: ['polish_prep'],
+ },
+ {
+ code: 'multi_unit_abutment',
+ sortOrder: 16,
+ manufacturingSteps: ['polish_prep'],
+ },
+ {
+ code: 'zirconia_abutment',
+ sortOrder: 17,
+ manufacturingSteps: ['milling_dry', 'sinter', 'polish_prep'],
+ },
+ {
+ code: 'screw_retained',
+ sortOrder: 18,
+ manufacturingSteps: [
+ 'milling_wet',
+ 'printer_metal',
+ 'sinter',
+ 'build_up',
+ 'stain',
+ 'glaze',
+ 'polish_prep',
+ ],
+ },
+ {
+ code: 'zirconia_overlay',
+ sortOrder: 19,
+ manufacturingSteps: ['milling_dry', 'sinter', 'stain', 'glaze', 'polish_prep'],
+ },
+ {
+ code: 'ips_overlay',
+ sortOrder: 20,
+ manufacturingSteps: ['milling_wet', 'stain', 'glaze', 'polish_prep'],
+ },
+ {
+ code: 'smile_design',
+ sortOrder: 21,
+ skipPackingShipping: true,
+ manufacturingSteps: ['printer_resin'],
+ },
+ {
+ code: 'mockup',
+ sortOrder: 22,
+ manufacturingSteps: ['printer_resin'],
+ },
+];
+
+const UNIVERSAL_PREFIX = ['intraoral_scan', 'design'] as const;
+const UNIVERSAL_SUFFIX = ['packing', 'shipping'] as const;
+
+export function buildProsthesisStepCodes(type: ProsthesisTypeSeed): string[] {
+ const steps = [...UNIVERSAL_PREFIX, ...type.manufacturingSteps];
+ if (!type.skipPackingShipping) {
+ steps.push(...UNIVERSAL_SUFFIX);
+ }
+ return steps;
+}
+
+const TREATMENT_LABELS: Record> = {
+ restoration: { en: 'Restoration', fa: 'ترمیم', nl: 'Restauratie' },
+ specialized_restoration: { en: 'Specialized Restoration', fa: 'ترمیم تخصصی', nl: 'Gespecialiseerde Restauratie' },
+ radiography: { en: 'Radiography', fa: 'رادیوگرافی', nl: 'Röntgen' },
+ endo: { en: 'Endo', fa: 'اندو', nl: 'Endo' },
+ surgery: { en: 'Surgery', fa: 'جراحی', nl: 'Chirurgie' },
+ prosthesis: { en: 'Prosthesis', fa: 'پروتز', nl: 'Prothese' },
+ implant: { en: 'Implant', fa: 'ایمپلنت', nl: 'Implantaat' },
+ orthodontics: { en: 'Orthodontics', fa: 'ارتودنسی', nl: 'Orthodontie' },
+ perio: { en: 'Perio', fa: 'پریو', nl: 'Paro' },
+ pediatrics: { en: 'Pediatrics', fa: 'اطفال', nl: 'Kinderen' },
+ extraction: { en: 'Extraction', fa: 'کشیدن', nl: 'Extractie' },
+ clinic_visit: { en: 'Clinic Visit', fa: 'درمانگاه', nl: 'Kliniekbezoek' },
+ consultation: { en: 'Consultation', fa: 'مشاوره', nl: 'Consult' },
+ filling: { en: 'Filling', fa: 'پر کردن', nl: 'Vulling' },
+ visit: { en: 'Visit', fa: 'ویزیت', nl: 'Bezoek' },
+ hygiene: { en: 'Hygiene', fa: 'بهداشت', nl: 'Hygiëne' },
+};
+
+const PROSTHESIS_LABELS: Record> = {
+ pfm_crown: { en: 'PFM Crown', fa: 'روکش PFM', nl: 'PFM Kroon' },
+ pfz_crown: { en: 'PFZ Crown', fa: 'روکش PFZ', nl: 'PFZ Kroon' },
+ monolithic_zirconia: { en: 'Monolithic Zirconia', fa: 'زیرکونیا مونولیتیک', nl: 'Monolithisch Zirconia' },
+ glass_ceramic_crown: { en: 'Glass Ceramic Crown', fa: 'روکش سرامیک شیشهای', nl: 'Glaskeramische Kroon' },
+ full_metal_crown: { en: 'Full Metal Crown', fa: 'روکش تمام فلز', nl: 'Volledige Metalen Kroon' },
+ temporary_resin_crown: { en: 'Temporary Resin Crown', fa: 'روکش موقت رزینی', nl: 'Tijdelijke Harskroon' },
+ pmma: { en: 'PMMA', fa: 'PMMA', nl: 'PMMA' },
+ peek_crown: { en: 'PEEK Crown', fa: 'روکش PEEK', nl: 'PEEK Kroon' },
+ veneer_zirconia: { en: 'Veneer Zirconia', fa: 'ونیر زیرکونیا', nl: 'Veneer Zirconia' },
+ veneer_ips_press: { en: 'Veneer IPS Press', fa: 'ونیر IPS پرس', nl: 'Veneer IPS Press' },
+ veneer_ips_cad: { en: 'Veneer IPS CAD', fa: 'ونیر IPS CAD', nl: 'Veneer IPS CAD' },
+ soft_structure: { en: 'Soft Structure', fa: 'ساختار نرم', nl: 'Zachte Structuur' },
+ customized_abutment: { en: 'Customized Abutment', fa: 'اباتمنت سفارشی', nl: 'Aangepast Abutment' },
+ prefabricated_abutment: { en: 'Prefabricated Abutment', fa: 'اباتمنت آماده', nl: 'Prefab Abutment' },
+ ti_base_abutment: { en: 'Ti Base Abutment', fa: 'اباتمنت پایه تیتانیوم', nl: 'Ti Basis Abutment' },
+ multi_unit_abutment: { en: 'Multi Unit Abutment', fa: 'اباتمنت مولتی یونیت', nl: 'Multi Unit Abutment' },
+ zirconia_abutment: { en: 'Zirconia Abutment', fa: 'اباتمنت زیرکونیا', nl: 'Zirconia Abutment' },
+ screw_retained: { en: 'Screw Retained', fa: 'پیچی', nl: 'Schroefgehouden' },
+ zirconia_overlay: { en: 'Zirconia Overlay', fa: 'اورلی زیرکونیا', nl: 'Zirconia Overlay' },
+ ips_overlay: { en: 'IPS Overlay', fa: 'اورلی IPS', nl: 'IPS Overlay' },
+ smile_design: { en: 'Smile Design', fa: 'طراحی لبخند', nl: 'Smile Design' },
+ mockup: { en: 'Mockup', fa: 'ماکاپ', nl: 'Mockup' },
+};
+
+const WORKFLOW_STEP_LABELS: Record> = {
+ intraoral_scan: { en: 'Intraoral Scan', fa: 'اسکن داخل دهان', nl: 'Intraorale Scan' },
+ design: { en: 'Design', fa: 'طراحی', nl: 'Ontwerp' },
+ milling_dry: { en: 'Milling Dry', fa: 'فرز خشک', nl: 'Droog Frezen' },
+ milling_wet: { en: 'Milling Wet', fa: 'فرز تر', nl: 'Nat Frezen' },
+ printer_resin: { en: 'Printer Resin', fa: 'پرینتر رزین', nl: 'Harsprinter' },
+ printer_metal: { en: 'Printer Metal', fa: 'پرینتر فلز', nl: 'Metaalprinter' },
+ sinter: { en: 'Sinter', fa: 'سینتر', nl: 'Sinteren' },
+ build_up: { en: 'Build Up', fa: 'بیلدآپ', nl: 'Opbouw' },
+ stain: { en: 'Stain', fa: 'رنگآمیزی', nl: 'Kleuren' },
+ glaze: { en: 'Glaze', fa: 'گلیز', nl: 'Glazuur' },
+ polish_prep: { en: 'Polish/Prep', fa: 'پولیش/آمادهسازی', nl: 'Polijsten/Voorbereiding' },
+ packing: { en: 'Packing', fa: 'بستهبندی', nl: 'Verpakken' },
+ shipping: { en: 'Shipping', fa: 'ارسال', nl: 'Verzending' },
+ pressing: { en: 'Pressing', fa: 'پرس', nl: 'Persen' },
+};
+
+function labelsToTranslations(
+ entityKind: CatalogEntityKind,
+ labels: Record>,
+): CatalogTranslationSeed[] {
+ const out: CatalogTranslationSeed[] = [];
+ for (const [entityCode, locales] of Object.entries(labels)) {
+ for (const [locale, label] of Object.entries(locales)) {
+ out.push({ entityKind, entityCode, locale, label });
+ }
+ }
+ return out;
+}
+
+export const CATALOG_TRANSLATIONS: CatalogTranslationSeed[] = [
+ ...labelsToTranslations(CatalogEntityKind.TREATMENT_TYPE, TREATMENT_LABELS),
+ ...labelsToTranslations(CatalogEntityKind.PROSTHESIS_TYPE, PROSTHESIS_LABELS),
+ ...labelsToTranslations(CatalogEntityKind.LAB_WORKFLOW_STEP, WORKFLOW_STEP_LABELS),
+];
diff --git a/backend/prisma/migrations/20260706120000_prosthesis_catalog/migration.sql b/backend/prisma/migrations/20260706120000_prosthesis_catalog/migration.sql
new file mode 100644
index 0000000..cf1dfee
--- /dev/null
+++ b/backend/prisma/migrations/20260706120000_prosthesis_catalog/migration.sql
@@ -0,0 +1,95 @@
+-- Prosthesis catalog refactor: drop treatment workflow steps, add prosthesis catalog tables
+
+CREATE TYPE "CatalogEntityKind" AS ENUM ('TREATMENT_TYPE', 'PROSTHESIS_TYPE', 'LAB_WORKFLOW_STEP');
+
+ALTER TABLE "treatment_types" ADD COLUMN "isActive" BOOLEAN NOT NULL DEFAULT true;
+
+DROP TABLE IF EXISTS "treatment_workflow_steps";
+
+CREATE TABLE "catalog_translations" (
+ "id" TEXT NOT NULL,
+ "entityKind" "CatalogEntityKind" NOT NULL,
+ "entityCode" TEXT NOT NULL,
+ "locale" TEXT NOT NULL,
+ "label" TEXT NOT NULL,
+
+ CONSTRAINT "catalog_translations_pkey" PRIMARY KEY ("id")
+);
+
+CREATE UNIQUE INDEX "catalog_translations_entityKind_entityCode_locale_key"
+ ON "catalog_translations"("entityKind", "entityCode", "locale");
+
+CREATE TABLE "prosthesis_types" (
+ "id" TEXT NOT NULL,
+ "code" TEXT NOT NULL,
+ "sortOrder" INTEGER NOT NULL DEFAULT 0,
+ "isActive" BOOLEAN NOT NULL DEFAULT true,
+ "skipPackingShipping" BOOLEAN NOT NULL DEFAULT false,
+
+ CONSTRAINT "prosthesis_types_pkey" PRIMARY KEY ("id")
+);
+
+CREATE UNIQUE INDEX "prosthesis_types_code_key" ON "prosthesis_types"("code");
+
+CREATE TABLE "lab_workflow_steps" (
+ "id" TEXT NOT NULL,
+ "code" TEXT NOT NULL,
+ "sortOrder" INTEGER NOT NULL DEFAULT 0,
+
+ CONSTRAINT "lab_workflow_steps_pkey" PRIMARY KEY ("id")
+);
+
+CREATE UNIQUE INDEX "lab_workflow_steps_code_key" ON "lab_workflow_steps"("code");
+
+CREATE TABLE "prosthesis_type_steps" (
+ "id" TEXT NOT NULL,
+ "prosthesisTypeId" TEXT NOT NULL,
+ "labWorkflowStepId" TEXT NOT NULL,
+ "stepOrder" INTEGER NOT NULL,
+
+ CONSTRAINT "prosthesis_type_steps_pkey" PRIMARY KEY ("id")
+);
+
+CREATE UNIQUE INDEX "prosthesis_type_steps_prosthesisTypeId_stepOrder_key"
+ ON "prosthesis_type_steps"("prosthesisTypeId", "stepOrder");
+
+CREATE UNIQUE INDEX "prosthesis_type_steps_prosthesisTypeId_labWorkflowStepId_key"
+ ON "prosthesis_type_steps"("prosthesisTypeId", "labWorkflowStepId");
+
+ALTER TABLE "prosthesis_type_steps"
+ ADD CONSTRAINT "prosthesis_type_steps_prosthesisTypeId_fkey"
+ FOREIGN KEY ("prosthesisTypeId") REFERENCES "prosthesis_types"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+ALTER TABLE "prosthesis_type_steps"
+ ADD CONSTRAINT "prosthesis_type_steps_labWorkflowStepId_fkey"
+ FOREIGN KEY ("labWorkflowStepId") REFERENCES "lab_workflow_steps"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+CREATE TABLE "lab_case_tooth_prosthesis" (
+ "id" TEXT NOT NULL,
+ "labCaseId" TEXT NOT NULL,
+ "treatmentDetailId" TEXT NOT NULL,
+ "tooth" TEXT NOT NULL,
+ "prosthesisTypeCode" TEXT NOT NULL,
+
+ CONSTRAINT "lab_case_tooth_prosthesis_pkey" PRIMARY KEY ("id")
+);
+
+CREATE UNIQUE INDEX "lab_case_tooth_prosthesis_labCaseId_treatmentDetailId_tooth_key"
+ ON "lab_case_tooth_prosthesis"("labCaseId", "treatmentDetailId", "tooth");
+
+ALTER TABLE "lab_case_tooth_prosthesis"
+ ADD CONSTRAINT "lab_case_tooth_prosthesis_labCaseId_fkey"
+ FOREIGN KEY ("labCaseId") REFERENCES "lab_cases"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+ALTER TABLE "lab_case_tooth_prosthesis"
+ ADD CONSTRAINT "lab_case_tooth_prosthesis_treatmentDetailId_fkey"
+ FOREIGN KEY ("treatmentDetailId") REFERENCES "treatment_details"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- Lab case tasks: add prosthesis fields and update unique constraint
+ALTER TABLE "lab_case_tasks" ADD COLUMN "prosthesisTypeCode" TEXT NOT NULL DEFAULT '';
+ALTER TABLE "lab_case_tasks" ADD COLUMN "workflowStepCode" TEXT NOT NULL DEFAULT '';
+
+DROP INDEX IF EXISTS "lab_case_tasks_labCaseId_tooth_treatmentType_stepOrder_key";
+
+CREATE UNIQUE INDEX "lab_case_tasks_labCaseId_treatmentDetailId_tooth_stepOrder_key"
+ ON "lab_case_tasks"("labCaseId", "treatmentDetailId", "tooth", "stepOrder");
diff --git a/backend/prisma/reset-treatment-data.ts b/backend/prisma/reset-treatment-data.ts
new file mode 100644
index 0000000..1b93fdc
--- /dev/null
+++ b/backend/prisma/reset-treatment-data.ts
@@ -0,0 +1,41 @@
+/**
+ * Dev-only: truncate treatment and lab case data (preserves catalog tables).
+ * Usage: npx ts-node prisma/reset-treatment-data.ts
+ */
+import { PrismaClient } from '@prisma/client';
+import { config } from 'dotenv';
+import path from 'path';
+
+const envPath = path.join(__dirname, '..', '.env');
+config({ path: envPath });
+
+if (process.env.NODE_ENV === 'production') {
+ console.error('reset-treatment-data is not allowed in production');
+ process.exit(1);
+}
+
+const prisma = new PrismaClient();
+
+async function main() {
+ console.log('Truncating treatment and lab case data...');
+
+ await prisma.$executeRawUnsafe('TRUNCATE TABLE "lab_case_tasks" CASCADE');
+ await prisma.$executeRawUnsafe('TRUNCATE TABLE "lab_case_sends" CASCADE');
+ await prisma.$executeRawUnsafe('TRUNCATE TABLE "lab_case_tooth_prosthesis" CASCADE');
+ await prisma.$executeRawUnsafe('TRUNCATE TABLE "lab_case_details" CASCADE');
+ await prisma.$executeRawUnsafe('TRUNCATE TABLE "lab_cases" CASCADE');
+ await prisma.$executeRawUnsafe('TRUNCATE TABLE "treatment_detail_attachments" CASCADE');
+ await prisma.$executeRawUnsafe('TRUNCATE TABLE "treatment_details" CASCADE');
+ await prisma.$executeRawUnsafe('TRUNCATE TABLE "treatments" CASCADE');
+
+ console.log('Done.');
+}
+
+main()
+ .catch((e) => {
+ console.error(e);
+ process.exit(1);
+ })
+ .finally(async () => {
+ await prisma.$disconnect();
+ });
diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma
index e453a29..828cc38 100644
--- a/backend/prisma/schema.prisma
+++ b/backend/prisma/schema.prisma
@@ -155,6 +155,7 @@ model TreatmentDetail {
attachments TreatmentDetailAttachment[]
labCaseLink LabCaseDetail?
labCaseTasks LabCaseTask[]
+ toothProsthesis LabCaseToothProsthesis[]
@@index([treatmentId, sortOrder])
@@map("treatment_details")
@@ -188,10 +189,11 @@ model LabCase {
labComment String?
sentAt DateTime?
- treatment Treatment @relation(fields: [treatmentId], references: [id], onDelete: Cascade)
- details LabCaseDetail[]
- sends LabCaseSend[]
- tasks LabCaseTask[]
+ treatment Treatment @relation(fields: [treatmentId], references: [id], onDelete: Cascade)
+ details LabCaseDetail[]
+ sends LabCaseSend[]
+ tasks LabCaseTask[]
+ toothProsthesis LabCaseToothProsthesis[]
@@index([treatmentId, sortOrder])
@@map("lab_cases")
@@ -226,36 +228,92 @@ model TreatmentType {
code String @unique
labDependent Boolean @default(false)
sortOrder Int @default(0)
-
- workflowSteps TreatmentWorkflowStep[]
+ isActive Boolean @default(true)
@@map("treatment_types")
}
-model TreatmentWorkflowStep {
- id String @id @default(uuid())
- treatmentTypeId String
- stepOrder Int
- label String
+enum CatalogEntityKind {
+ TREATMENT_TYPE
+ PROSTHESIS_TYPE
+ LAB_WORKFLOW_STEP
+}
- treatmentType TreatmentType @relation(fields: [treatmentTypeId], references: [id], onDelete: Cascade)
+model CatalogTranslation {
+ id String @id @default(uuid())
+ entityKind CatalogEntityKind
+ entityCode String
+ locale String
+ label String
- @@unique([treatmentTypeId, stepOrder])
- @@map("treatment_workflow_steps")
+ @@unique([entityKind, entityCode, locale])
+ @@map("catalog_translations")
+}
+
+model ProsthesisType {
+ id String @id @default(uuid())
+ code String @unique
+ sortOrder Int @default(0)
+ isActive Boolean @default(true)
+ skipPackingShipping Boolean @default(false)
+
+ steps ProsthesisTypeStep[]
+
+ @@map("prosthesis_types")
+}
+
+model LabWorkflowStep {
+ id String @id @default(uuid())
+ code String @unique
+ sortOrder Int @default(0)
+
+ prosthesisSteps ProsthesisTypeStep[]
+
+ @@map("lab_workflow_steps")
+}
+
+model ProsthesisTypeStep {
+ id String @id @default(uuid())
+ prosthesisTypeId String
+ labWorkflowStepId String
+ stepOrder Int
+
+ prosthesisType ProsthesisType @relation(fields: [prosthesisTypeId], references: [id], onDelete: Cascade)
+ labWorkflowStep LabWorkflowStep @relation(fields: [labWorkflowStepId], references: [id], onDelete: Cascade)
+
+ @@unique([prosthesisTypeId, stepOrder])
+ @@unique([prosthesisTypeId, labWorkflowStepId])
+ @@map("prosthesis_type_steps")
+}
+
+model LabCaseToothProsthesis {
+ id String @id @default(uuid())
+ labCaseId String
+ treatmentDetailId String
+ tooth String
+ prosthesisTypeCode String
+
+ labCase LabCase @relation(fields: [labCaseId], references: [id], onDelete: Cascade)
+ detail TreatmentDetail @relation(fields: [treatmentDetailId], references: [id], onDelete: Cascade)
+
+ @@unique([labCaseId, treatmentDetailId, tooth])
+ @@map("lab_case_tooth_prosthesis")
}
model LabCaseTask {
- id String @id @default(uuid())
- labCaseId String
- treatmentDetailId String
- tooth String
- treatmentType String
- stepOrder Int
- stepLabel String
- assigneeUserId String?
- assignedAt DateTime?
- priority Int @default(3)
- status LabTaskStatus @default(PENDING)
+ id String @id @default(uuid())
+ labCaseId String
+ treatmentDetailId String
+ tooth String
+ treatmentType String
+ prosthesisTypeCode String
+ workflowStepCode String
+ stepOrder Int
+ stepLabel String
+ assigneeUserId String?
+ assignedAt DateTime?
+ priority Int @default(3)
+ status LabTaskStatus @default(PENDING)
labCase LabCase @relation(fields: [labCaseId], references: [id], onDelete: Cascade)
detail TreatmentDetail @relation(fields: [treatmentDetailId], references: [id], onDelete: Cascade)
@@ -264,7 +322,7 @@ model LabCaseTask {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
- @@unique([labCaseId, tooth, treatmentType, stepOrder])
+ @@unique([labCaseId, treatmentDetailId, tooth, stepOrder])
@@index([labCaseId, status])
@@index([assigneeUserId, priority, createdAt])
@@index([assignedAt, labCaseId, priority])
diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts
index ad968ed..bbe550c 100644
--- a/backend/prisma/seed.ts
+++ b/backend/prisma/seed.ts
@@ -3,6 +3,14 @@ import { PrismaClient } from '@prisma/client';
import { randomUUID } from 'crypto';
import { config } from 'dotenv';
import path from 'path';
+import {
+ TREATMENT_TYPES,
+ LEGACY_TREATMENT_TYPES,
+ LAB_WORKFLOW_STEPS,
+ PROSTHESIS_TYPES,
+ CATALOG_TRANSLATIONS,
+ buildProsthesisStepCodes,
+} from './catalog-seed-data';
// Load environment variables from the correct path
const envPath = path.join(__dirname, '..', '.env');
@@ -128,57 +136,118 @@ 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) {
+ for (const type of [...TREATMENT_TYPES, ...LEGACY_TREATMENT_TYPES]) {
+ const isActive = TREATMENT_TYPES.some((t) => t.code === type.code);
await prisma.treatmentType.upsert({
where: { code: type.code },
- update: { labDependent: type.labDependent, sortOrder: type.sortOrder },
+ update: {
+ labDependent: type.labDependent,
+ sortOrder: type.sortOrder,
+ isActive,
+ },
create: {
id: randomUUID(),
code: type.code,
labDependent: type.labDependent,
sortOrder: type.sortOrder,
+ isActive,
},
});
}
console.log('✅ Seeded treatment type catalog');
- for (const step of workflowSteps) {
- const treatmentType = await prisma.treatmentType.findUniqueOrThrow({
+ for (const step of LAB_WORKFLOW_STEPS) {
+ await prisma.labWorkflowStep.upsert({
where: { code: step.code },
- select: { id: true },
- });
-
- await prisma.treatmentWorkflowStep.upsert({
- where: {
- treatmentTypeId_stepOrder: {
- treatmentTypeId: treatmentType.id,
- stepOrder: step.stepOrder,
- },
- },
- update: { label: step.label },
+ update: { sortOrder: step.sortOrder },
create: {
id: randomUUID(),
- treatmentTypeId: treatmentType.id,
- stepOrder: step.stepOrder,
- label: step.label,
+ code: step.code,
+ sortOrder: step.sortOrder,
},
});
}
console.log('✅ Seeded lab workflow steps');
+ const workflowStepByCode = new Map(
+ (
+ await prisma.labWorkflowStep.findMany({
+ select: { id: true, code: true },
+ })
+ ).map((s) => [s.code, s.id]),
+ );
+
+ for (const type of PROSTHESIS_TYPES) {
+ const prosthesisType = await prisma.prosthesisType.upsert({
+ where: { code: type.code },
+ update: {
+ sortOrder: type.sortOrder,
+ skipPackingShipping: type.skipPackingShipping ?? false,
+ isActive: true,
+ },
+ create: {
+ id: randomUUID(),
+ code: type.code,
+ sortOrder: type.sortOrder,
+ skipPackingShipping: type.skipPackingShipping ?? false,
+ isActive: true,
+ },
+ });
+
+ const stepCodes = buildProsthesisStepCodes(type);
+ for (const [index, stepCode] of stepCodes.entries()) {
+ const labWorkflowStepId = workflowStepByCode.get(stepCode);
+ if (!labWorkflowStepId) {
+ throw new Error(`Unknown workflow step code: ${stepCode}`);
+ }
+
+ await prisma.prosthesisTypeStep.upsert({
+ where: {
+ prosthesisTypeId_stepOrder: {
+ prosthesisTypeId: prosthesisType.id,
+ stepOrder: index + 1,
+ },
+ },
+ update: { labWorkflowStepId },
+ create: {
+ id: randomUUID(),
+ prosthesisTypeId: prosthesisType.id,
+ labWorkflowStepId,
+ stepOrder: index + 1,
+ },
+ });
+ }
+ }
+ console.log('✅ Seeded prosthesis types and workflow mappings');
+
+ for (const tr of CATALOG_TRANSLATIONS) {
+ const existing = await prisma.catalogTranslation.findFirst({
+ where: {
+ entityKind: tr.entityKind,
+ entityCode: tr.entityCode,
+ locale: tr.locale,
+ },
+ });
+
+ if (existing) {
+ await prisma.catalogTranslation.update({
+ where: { id: existing.id },
+ data: { label: tr.label },
+ });
+ } else {
+ await prisma.catalogTranslation.create({
+ data: {
+ id: randomUUID(),
+ entityKind: tr.entityKind,
+ entityCode: tr.entityCode,
+ locale: tr.locale,
+ label: tr.label,
+ },
+ });
+ }
+ }
+ console.log('✅ Seeded catalog translations');
+
console.log('🌱 Seeding completed successfully!');
}
diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts
index e67e7f2..6c2aab6 100644
--- a/backend/src/app.module.ts
+++ b/backend/src/app.module.ts
@@ -14,6 +14,8 @@ import { TreatmentsModule } from './modules/treatments/treatments.module';
import { CasesModule } from './modules/cases/cases.module';
import { TasksModule } from './modules/tasks/tasks.module';
import { TreatmentCatalogModule } from './modules/treatment-catalog/treatment-catalog.module';
+import { CatalogModule } from './modules/catalog/catalog.module';
+import { ProsthesisCatalogModule } from './modules/prosthesis-catalog/prosthesis-catalog.module';
@Module({
imports: [
@@ -22,7 +24,9 @@ import { TreatmentCatalogModule } from './modules/treatment-catalog/treatment-ca
load: [configurations],
}),
PrismaModule, // ✅ ADD THIS
+ CatalogModule,
TreatmentCatalogModule,
+ ProsthesisCatalogModule,
AuthModule,
PatientsModule,
AppointmentsModule,
diff --git a/backend/src/modules/cases/cases.controller.ts b/backend/src/modules/cases/cases.controller.ts
index d463022..58ec144 100644
--- a/backend/src/modules/cases/cases.controller.ts
+++ b/backend/src/modules/cases/cases.controller.ts
@@ -46,7 +46,7 @@ export class CasesController {
@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);
+ return this.casesService.getOne(id, organizationId, req.user.id, req.user.language);
}
@Patch(':id/tasks/:taskId')
@@ -58,6 +58,6 @@ export class CasesController {
@Req() req,
) {
const organizationId = this.casesService.getOrganizationIdFromUser(req.user);
- return this.casesService.updateTask(id, taskId, dto, organizationId, req.user.id);
+ return this.casesService.updateTask(id, taskId, dto, organizationId, req.user.id, req.user.language);
}
}
diff --git a/backend/src/modules/cases/cases.service.ts b/backend/src/modules/cases/cases.service.ts
index 401e4d9..733a478 100644
--- a/backend/src/modules/cases/cases.service.ts
+++ b/backend/src/modules/cases/cases.service.ts
@@ -4,9 +4,13 @@ import {
Injectable,
NotFoundException,
} from '@nestjs/common';
-import { LabTaskStatus, Prisma } from '@prisma/client';
+import { CatalogEntityKind, LabTaskStatus, Prisma } from '@prisma/client';
import { PrismaService } from '../../../prisma/prisma.service';
import { normalizeMobile } from '../../common/phone';
+import {
+ CatalogLabelService,
+ normalizeCatalogLocale,
+} from '../catalog/catalog-label.service';
import { TreatmentCatalogService } from '../treatment-catalog/treatment-catalog.service';
import { normalizeTeeth } from '../treatments/treatment.utils';
import { ListLabCasesDto, UpdateLabCaseTaskDto } from './dto/cases.dto';
@@ -52,6 +56,7 @@ export class CasesService {
constructor(
private readonly prisma: PrismaService,
private readonly treatmentCatalog: TreatmentCatalogService,
+ private readonly catalogLabels: CatalogLabelService,
) {}
getOrganizationIdFromUser(user: { organizationId?: string }) {
@@ -142,8 +147,8 @@ export class CasesService {
}
}
- const treatmentTypes = this.treatmentCatalog
- .list()
+ const catalog = await this.treatmentCatalog.list();
+ const treatmentTypes = catalog
.filter((entry) => entry.labDependent && typeCodes.has(entry.code))
.map((entry) => ({ code: entry.code, labDependent: entry.labDependent }));
@@ -218,6 +223,7 @@ export class CasesService {
labCaseId: string,
clinicOrganizationId: string,
labOrganizationId: string,
+ localeInput?: string | null,
) {
const labCase = await this.prisma.labCase.findFirst({
where: {
@@ -233,10 +239,18 @@ export class CasesService {
throw new NotFoundException('Case not found');
}
- return { success: true, data: this.mapLabCaseDetail(labCase) };
+ return {
+ success: true,
+ data: await this.mapLabCaseDetail(labCase, localeInput),
+ };
}
- async getOne(labCaseId: string, labOrganizationId: string, actorUserId: string) {
+ async getOne(
+ labCaseId: string,
+ labOrganizationId: string,
+ actorUserId: string,
+ localeInput?: string | null,
+ ) {
await this.assertCanReadCases(actorUserId, labOrganizationId);
const labCase = await this.prisma.labCase.findFirst({
@@ -252,7 +266,7 @@ export class CasesService {
throw new NotFoundException('Case not found');
}
- return { success: true, data: this.mapLabCaseDetail(labCase) };
+ return { success: true, data: await this.mapLabCaseDetail(labCase, localeInput) };
}
async updateTask(
@@ -261,6 +275,7 @@ export class CasesService {
dto: UpdateLabCaseTaskDto,
labOrganizationId: string,
actorUserId: string,
+ localeInput?: string | null,
) {
await this.assertCanEditCases(actorUserId, labOrganizationId);
@@ -299,7 +314,14 @@ export class CasesService {
},
});
- return { success: true, data: this.mapTask(updated) };
+ const locale = normalizeCatalogLocale(localeInput);
+ const prosthesisLabels = await this.catalogLabels.resolveLabels(
+ CatalogEntityKind.PROSTHESIS_TYPE,
+ [updated.prosthesisTypeCode],
+ locale,
+ );
+
+ return { success: true, data: this.mapTask(updated, prosthesisLabels) };
}
async listAssignableMembers(labOrganizationId: string, actorUserId: string) {
@@ -431,9 +453,19 @@ export class CasesService {
};
}
- private mapLabCaseDetail(lc: Prisma.LabCaseGetPayload<{ include: typeof labCaseListInclude }>) {
+ private async mapLabCaseDetail(
+ lc: Prisma.LabCaseGetPayload<{ include: typeof labCaseListInclude }>,
+ localeInput?: string | null,
+ ) {
+ const locale = normalizeCatalogLocale(localeInput);
const treatmentTypes = [...new Set(lc.details.map((d) => d.detail.treatmentType))];
- const tasksByTooth = this.groupTasksByTooth(lc.tasks);
+ const prosthesisCodes = [...new Set(lc.tasks.map((t) => t.prosthesisTypeCode).filter(Boolean))];
+ const prosthesisLabels = await this.catalogLabels.resolveLabels(
+ CatalogEntityKind.PROSTHESIS_TYPE,
+ prosthesisCodes,
+ locale,
+ );
+ const tasksByTooth = this.groupTasksByTooth(lc.tasks, prosthesisLabels);
return {
id: lc.id,
@@ -454,7 +486,7 @@ export class CasesService {
organizationName: s.organization.name,
sentAt: s.sentAt.toISOString(),
})),
- tasks: lc.tasks.map((t) => this.mapTask(t)),
+ tasks: lc.tasks.map((t) => this.mapTask(t, prosthesisLabels)),
tasksByTooth,
taskProgress: {
completed: lc.tasks.filter((t) => t.status === LabTaskStatus.COMPLETED).length,
@@ -468,6 +500,7 @@ export class CasesService {
id: string;
tooth: string;
treatmentType: string;
+ prosthesisTypeCode: string;
stepOrder: number;
stepLabel: string;
status: LabTaskStatus;
@@ -477,47 +510,62 @@ export class CasesService {
createdAt: Date;
assignee: { id: string; name: string; email: string } | null;
}>,
+ prosthesisLabels: Map,
) {
const groups = new Map<
string,
{
tooth: string;
treatmentType: string;
+ prosthesisTypeCode: string;
+ prosthesisTypeLabel: string;
tasks: ReturnType[];
}
>();
for (const task of tasks) {
- const key = `${task.tooth}:${task.treatmentType}`;
+ const key = `${task.tooth}:${task.treatmentType}:${task.prosthesisTypeCode}`;
const entry = groups.get(key) ?? {
tooth: task.tooth,
treatmentType: task.treatmentType,
+ prosthesisTypeCode: task.prosthesisTypeCode,
+ prosthesisTypeLabel:
+ prosthesisLabels.get(task.prosthesisTypeCode) ?? task.prosthesisTypeCode,
tasks: [],
};
- entry.tasks.push(this.mapTask(task));
+ entry.tasks.push(this.mapTask(task, prosthesisLabels));
groups.set(key, entry);
}
return [...groups.values()];
}
- private mapTask(task: {
- id: string;
- tooth: string;
- treatmentType: string;
- stepOrder: number;
- stepLabel: string;
- status: LabTaskStatus;
- priority: number;
- assigneeUserId: string | null;
- assignedAt: Date | null;
- createdAt: Date;
- assignee: { id: string; name: string; email: string } | null;
- }) {
+ private mapTask(
+ task: {
+ id: string;
+ tooth: string;
+ treatmentType: string;
+ prosthesisTypeCode: string;
+ workflowStepCode?: string;
+ stepOrder: number;
+ stepLabel: string;
+ status: LabTaskStatus;
+ priority: number;
+ assigneeUserId: string | null;
+ assignedAt: Date | null;
+ createdAt: Date;
+ assignee: { id: string; name: string; email: string } | null;
+ },
+ prosthesisLabels: Map,
+ ) {
return {
id: task.id,
tooth: task.tooth,
treatmentType: task.treatmentType,
+ prosthesisTypeCode: task.prosthesisTypeCode,
+ prosthesisTypeLabel:
+ prosthesisLabels.get(task.prosthesisTypeCode) ?? task.prosthesisTypeCode,
+ workflowStepCode: task.workflowStepCode ?? '',
stepOrder: task.stepOrder,
stepLabel: task.stepLabel,
status: task.status,
diff --git a/backend/src/modules/cases/lab-case-task.generator.spec.ts b/backend/src/modules/cases/lab-case-task.generator.spec.ts
new file mode 100644
index 0000000..56d7350
--- /dev/null
+++ b/backend/src/modules/cases/lab-case-task.generator.spec.ts
@@ -0,0 +1,154 @@
+import { CatalogEntityKind } from '@prisma/client';
+import {
+ PROSTHESIS_TYPES,
+ buildProsthesisStepCodes,
+} from '../../../prisma/catalog-seed-data';
+import { generateLabCaseTasks } from './lab-case-task.generator';
+
+function buildMockTx(options: {
+ existingCount?: number;
+ toothProsthesisRows: Array<{
+ treatmentDetailId: string;
+ tooth: string;
+ prosthesisTypeCode: string;
+ treatmentType?: string;
+ }>;
+ prosthesisTypes: Array<{
+ code: string;
+ steps: Array<{ stepOrder: number; workflowStepCode: string }>;
+ }>;
+ stepLabels?: Record;
+}) {
+ const created: unknown[] = [];
+
+ const tx = {
+ labCaseTask: {
+ count: jest.fn().mockResolvedValue(options.existingCount ?? 0),
+ createMany: jest.fn().mockImplementation(({ data }) => {
+ created.push(...data);
+ return { count: data.length };
+ }),
+ },
+ labCaseToothProsthesis: {
+ findMany: jest.fn().mockResolvedValue(
+ options.toothProsthesisRows.map((row) => ({
+ treatmentDetailId: row.treatmentDetailId,
+ tooth: row.tooth,
+ prosthesisTypeCode: row.prosthesisTypeCode,
+ detail: {
+ id: row.treatmentDetailId,
+ treatmentType: row.treatmentType ?? 'prosthesis',
+ },
+ })),
+ ),
+ },
+ prosthesisType: {
+ findMany: jest.fn().mockResolvedValue(
+ options.prosthesisTypes.map((type) => ({
+ code: type.code,
+ isActive: true,
+ steps: type.steps.map((step) => ({
+ stepOrder: step.stepOrder,
+ labWorkflowStep: { code: step.workflowStepCode },
+ })),
+ })),
+ ),
+ },
+ catalogTranslation: {
+ findMany: jest.fn().mockImplementation(({ where }) => {
+ const codes = where.entityCode?.in ?? [];
+ return codes.map((code: string) => ({
+ entityCode: code,
+ locale: 'en',
+ label: options.stepLabels?.[code] ?? code,
+ entityKind: CatalogEntityKind.LAB_WORKFLOW_STEP,
+ }));
+ }),
+ },
+ };
+
+ return { tx, created };
+}
+
+function stepsFromSeed(code: string) {
+ const seed = PROSTHESIS_TYPES.find((type) => type.code === code);
+ if (!seed) {
+ throw new Error(`Unknown prosthesis code: ${code}`);
+ }
+ return buildProsthesisStepCodes(seed).map((workflowStepCode, index) => ({
+ stepOrder: index + 1,
+ workflowStepCode,
+ }));
+}
+
+describe('generateLabCaseTasks', () => {
+ it('creates tasks for pfm_crown with universal and type-specific steps', async () => {
+ const pfmSteps = stepsFromSeed('pfm_crown');
+ const { tx, created } = buildMockTx({
+ toothProsthesisRows: [
+ {
+ treatmentDetailId: 'detail-1',
+ tooth: '14',
+ prosthesisTypeCode: 'pfm_crown',
+ },
+ ],
+ prosthesisTypes: [{ code: 'pfm_crown', steps: pfmSteps }],
+ stepLabels: { intraoral_scan: 'Intraoral Scan', packing: 'Packing' },
+ });
+
+ const count = await generateLabCaseTasks(tx as never, 'lab-case-1', 'en');
+
+ expect(count).toBe(pfmSteps.length);
+ expect(created).toHaveLength(pfmSteps.length);
+ expect(created[0]).toMatchObject({
+ tooth: '14',
+ prosthesisTypeCode: 'pfm_crown',
+ workflowStepCode: 'intraoral_scan',
+ stepLabel: 'Intraoral Scan',
+ });
+ const stepCodes = (created as Array<{ workflowStepCode: string }>).map(
+ (row) => row.workflowStepCode,
+ );
+ expect(stepCodes).toEqual(pfmSteps.map((step) => step.workflowStepCode));
+ expect(stepCodes).toContain('packing');
+ expect(stepCodes).toContain('shipping');
+ expect(stepCodes).toContain('milling_wet');
+ });
+
+ it('omits packing and shipping for smile_design', async () => {
+ const smileSteps = stepsFromSeed('smile_design');
+ const { tx, created } = buildMockTx({
+ toothProsthesisRows: [
+ {
+ treatmentDetailId: 'detail-1',
+ tooth: '11',
+ prosthesisTypeCode: 'smile_design',
+ },
+ ],
+ prosthesisTypes: [{ code: 'smile_design', steps: smileSteps }],
+ });
+
+ const count = await generateLabCaseTasks(tx as never, 'lab-case-2', 'en');
+
+ expect(count).toBe(smileSteps.length);
+ const stepCodes = (created as Array<{ workflowStepCode: string }>).map(
+ (row) => row.workflowStepCode,
+ );
+ expect(stepCodes).not.toContain('packing');
+ expect(stepCodes).not.toContain('shipping');
+ expect(stepCodes).toContain('printer_resin');
+ });
+
+ it('skips generation when tasks already exist', async () => {
+ const { tx } = buildMockTx({
+ existingCount: 3,
+ toothProsthesisRows: [],
+ prosthesisTypes: [],
+ });
+
+ const count = await generateLabCaseTasks(tx as never, 'lab-case-3', 'en');
+
+ expect(count).toBe(0);
+ expect(tx.labCaseTask.createMany).not.toHaveBeenCalled();
+ });
+});
diff --git a/backend/src/modules/cases/lab-case-task.generator.ts b/backend/src/modules/cases/lab-case-task.generator.ts
index 8474b03..bac6c3e 100644
--- a/backend/src/modules/cases/lab-case-task.generator.ts
+++ b/backend/src/modules/cases/lab-case-task.generator.ts
@@ -1,84 +1,83 @@
-import { LabTaskStatus, Prisma } from '@prisma/client';
-import { normalizeTeeth } from '../treatments/treatment.utils';
+import { CatalogEntityKind, LabTaskStatus, Prisma } from '@prisma/client';
+import { normalizeCatalogLocale } from '../catalog/catalog-label.service';
type TransactionClient = Prisma.TransactionClient;
export async function generateLabCaseTasks(
tx: TransactionClient,
labCaseId: string,
+ localeInput?: string | null,
): Promise {
const existingCount = await tx.labCaseTask.count({ where: { labCaseId } });
if (existingCount > 0) {
return 0;
}
- const labCase = await tx.labCase.findUnique({
- where: { id: labCaseId },
+ const locale = normalizeCatalogLocale(localeInput);
+
+ const toothProsthesisRows = await tx.labCaseToothProsthesis.findMany({
+ where: { labCaseId },
include: {
- details: {
- include: {
- detail: {
- select: { id: true, treatmentType: true, teeth: true },
- },
- },
+ detail: { select: { id: true, treatmentType: true } },
+ },
+ });
+
+ if (toothProsthesisRows.length === 0) {
+ return 0;
+ }
+
+ const prosthesisCodes = [...new Set(toothProsthesisRows.map((r) => r.prosthesisTypeCode))];
+
+ const prosthesisTypes = await tx.prosthesisType.findMany({
+ where: { code: { in: prosthesisCodes }, isActive: true },
+ include: {
+ steps: {
+ orderBy: { stepOrder: 'asc' },
+ include: { labWorkflowStep: { select: { code: true } } },
},
},
});
- if (!labCase?.details.length) {
- return 0;
- }
+ const stepsByProsthesisCode = new Map(
+ prosthesisTypes.map((type) => [
+ type.code,
+ type.steps.map((s) => ({
+ stepOrder: s.stepOrder,
+ workflowStepCode: s.labWorkflowStep.code,
+ })),
+ ]),
+ );
- const treatmentTypeCodes = [...new Set(labCase.details.map((d) => d.detail.treatmentType))];
+ const allStepCodes = [
+ ...new Set(
+ prosthesisTypes.flatMap((type) =>
+ type.steps.map((s) => s.labWorkflowStep.code),
+ ),
+ ),
+ ];
- 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 stepLabels = await resolveStepLabels(tx, allStepCodes, locale);
const taskRows: Prisma.LabCaseTaskCreateManyInput[] = [];
- for (const link of labCase.details) {
- const detail = link.detail;
- if (!labDependentCodes.has(detail.treatmentType)) {
+ for (const row of toothProsthesisRows) {
+ const typeSteps = stepsByProsthesisCode.get(row.prosthesisTypeCode) ?? [];
+ if (typeSteps.length === 0) {
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,
- });
- }
+ for (const step of typeSteps) {
+ taskRows.push({
+ labCaseId,
+ treatmentDetailId: row.treatmentDetailId,
+ tooth: row.tooth,
+ treatmentType: row.detail.treatmentType,
+ prosthesisTypeCode: row.prosthesisTypeCode,
+ workflowStepCode: step.workflowStepCode,
+ stepOrder: step.stepOrder,
+ stepLabel: stepLabels.get(step.workflowStepCode) ?? step.workflowStepCode,
+ status: LabTaskStatus.PENDING,
+ });
}
}
@@ -89,3 +88,37 @@ export async function generateLabCaseTasks(
await tx.labCaseTask.createMany({ data: taskRows });
return taskRows.length;
}
+
+async function resolveStepLabels(
+ tx: TransactionClient,
+ stepCodes: string[],
+ locale: string,
+): Promise> {
+ if (stepCodes.length === 0) {
+ return new Map();
+ }
+
+ const rows = await tx.catalogTranslation.findMany({
+ where: {
+ entityKind: CatalogEntityKind.LAB_WORKFLOW_STEP,
+ entityCode: { in: stepCodes },
+ locale: { in: [locale, 'en'] },
+ },
+ select: { entityCode: true, locale: true, label: true },
+ });
+
+ const byCode = new Map();
+ for (const row of rows) {
+ const entry = byCode.get(row.entityCode) ?? {};
+ if (row.locale === 'en') entry.en = row.label;
+ if (row.locale === locale) entry.locale = row.label;
+ byCode.set(row.entityCode, entry);
+ }
+
+ const result = new Map();
+ for (const code of stepCodes) {
+ const entry = byCode.get(code);
+ result.set(code, entry?.locale ?? entry?.en ?? code);
+ }
+ return result;
+}
diff --git a/backend/src/modules/catalog/catalog-label.service.ts b/backend/src/modules/catalog/catalog-label.service.ts
new file mode 100644
index 0000000..7eb4206
--- /dev/null
+++ b/backend/src/modules/catalog/catalog-label.service.ts
@@ -0,0 +1,67 @@
+import { Injectable } from '@nestjs/common';
+import { CatalogEntityKind } from '@prisma/client';
+import { PrismaService } from '../../../prisma/prisma.service';
+
+const SUPPORTED_LOCALES = ['en', 'fa', 'nl'] as const;
+export type CatalogLocale = (typeof SUPPORTED_LOCALES)[number];
+
+export function normalizeCatalogLocale(language?: string | null): CatalogLocale {
+ if (language === 'fa' || language === 'nl') return language;
+ return 'en';
+}
+
+@Injectable()
+export class CatalogLabelService {
+ constructor(private readonly prisma: PrismaService) {}
+
+ async resolveLabels(
+ entityKind: CatalogEntityKind,
+ codes: string[],
+ locale: CatalogLocale,
+ ): Promise> {
+ const uniqueCodes = [...new Set(codes.filter(Boolean))];
+ if (uniqueCodes.length === 0) {
+ return new Map();
+ }
+
+ const rows = await this.prisma.catalogTranslation.findMany({
+ where: {
+ entityKind,
+ entityCode: { in: uniqueCodes },
+ locale: { in: [locale, 'en'] },
+ },
+ select: { entityCode: true, locale: true, label: true },
+ });
+
+ const byCode = new Map();
+ for (const row of rows) {
+ const entry = byCode.get(row.entityCode) ?? {};
+ if (row.locale === 'en') entry.en = row.label;
+ if (row.locale === locale) entry.locale = row.label;
+ byCode.set(row.entityCode, entry);
+ }
+
+ const result = new Map();
+ for (const code of uniqueCodes) {
+ const entry = byCode.get(code);
+ result.set(code, entry?.locale ?? entry?.en ?? formatCodeAsLabel(code));
+ }
+ return result;
+ }
+
+ async resolveLabel(
+ entityKind: CatalogEntityKind,
+ code: string,
+ locale: CatalogLocale,
+ ): Promise {
+ const map = await this.resolveLabels(entityKind, [code], locale);
+ return map.get(code) ?? formatCodeAsLabel(code);
+ }
+}
+
+export function formatCodeAsLabel(code: string): string {
+ return code
+ .split('_')
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
+ .join(' ');
+}
diff --git a/backend/src/modules/catalog/catalog.module.ts b/backend/src/modules/catalog/catalog.module.ts
new file mode 100644
index 0000000..da02200
--- /dev/null
+++ b/backend/src/modules/catalog/catalog.module.ts
@@ -0,0 +1,10 @@
+import { Global, Module } from '@nestjs/common';
+import { PrismaService } from '../../../prisma/prisma.service';
+import { CatalogLabelService } from './catalog-label.service';
+
+@Global()
+@Module({
+ providers: [CatalogLabelService, PrismaService],
+ exports: [CatalogLabelService],
+})
+export class CatalogModule {}
diff --git a/backend/src/modules/organization/organization.controller.ts b/backend/src/modules/organization/organization.controller.ts
index 90db4e4..9f3ff7d 100644
--- a/backend/src/modules/organization/organization.controller.ts
+++ b/backend/src/modules/organization/organization.controller.ts
@@ -143,7 +143,7 @@ export class OrganizationController {
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: 'Get one case exchanged with a connected organization' })
getConnectionCase(
- @Req() req: { user: { id: string; organizationId?: string } },
+ @Req() req: { user: { id: string; organizationId?: string; language?: string | null } },
@Param('connectionId') connectionId: string,
@Param('caseId') caseId: string,
) {
@@ -153,6 +153,7 @@ export class OrganizationController {
organizationId,
connectionId,
caseId,
+ req.user.language,
);
}
diff --git a/backend/src/modules/organization/organization.service.ts b/backend/src/modules/organization/organization.service.ts
index fc13621..6abb5b1 100644
--- a/backend/src/modules/organization/organization.service.ts
+++ b/backend/src/modules/organization/organization.service.ts
@@ -379,6 +379,7 @@ export class OrganizationService {
organizationId: string,
connectionId: string,
caseId: string,
+ localeInput?: string | null,
) {
const actor = await this.getActorMembership(userId, organizationId);
if (!actor || !this.canEditOrganizations(actor)) {
@@ -392,6 +393,7 @@ export class OrganizationService {
caseId,
clinicOrganizationId,
labOrganizationId,
+ localeInput,
);
return {
diff --git a/backend/src/modules/prosthesis-catalog/prosthesis-catalog.controller.ts b/backend/src/modules/prosthesis-catalog/prosthesis-catalog.controller.ts
new file mode 100644
index 0000000..f0c7bf5
--- /dev/null
+++ b/backend/src/modules/prosthesis-catalog/prosthesis-catalog.controller.ts
@@ -0,0 +1,26 @@
+import { Controller, Get, Query, Req, UseGuards } from '@nestjs/common';
+import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
+import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
+import { ProsthesisCatalogService } from './prosthesis-catalog.service';
+
+@ApiTags('prosthesis-catalog')
+@ApiBearerAuth('JWT-auth')
+@UseGuards(JwtAuthGuard)
+@Controller('prosthesis-catalog')
+export class ProsthesisCatalogController {
+ constructor(private readonly prosthesisCatalogService: ProsthesisCatalogService) {}
+
+ @Get()
+ @ApiOperation({
+ summary: 'List prosthesis types (optionally scoped to lab — v1 returns all types)',
+ })
+ list(
+ @Req() req: { user?: { language?: string | null } },
+ @Query('labOrganizationId') _labOrganizationId?: string,
+ ) {
+ return this.prosthesisCatalogService.list(req.user?.language).then((data) => ({
+ success: true,
+ data,
+ }));
+ }
+}
diff --git a/backend/src/modules/prosthesis-catalog/prosthesis-catalog.module.ts b/backend/src/modules/prosthesis-catalog/prosthesis-catalog.module.ts
new file mode 100644
index 0000000..17933b1
--- /dev/null
+++ b/backend/src/modules/prosthesis-catalog/prosthesis-catalog.module.ts
@@ -0,0 +1,11 @@
+import { Module } from '@nestjs/common';
+import { PrismaService } from '../../../prisma/prisma.service';
+import { ProsthesisCatalogController } from './prosthesis-catalog.controller';
+import { ProsthesisCatalogService } from './prosthesis-catalog.service';
+
+@Module({
+ controllers: [ProsthesisCatalogController],
+ providers: [ProsthesisCatalogService, PrismaService],
+ exports: [ProsthesisCatalogService],
+})
+export class ProsthesisCatalogModule {}
diff --git a/backend/src/modules/prosthesis-catalog/prosthesis-catalog.service.ts b/backend/src/modules/prosthesis-catalog/prosthesis-catalog.service.ts
new file mode 100644
index 0000000..7a0f0d2
--- /dev/null
+++ b/backend/src/modules/prosthesis-catalog/prosthesis-catalog.service.ts
@@ -0,0 +1,98 @@
+import { Injectable, BadRequestException, OnModuleInit } from '@nestjs/common';
+import { CatalogEntityKind } from '@prisma/client';
+import { PrismaService } from '../../../prisma/prisma.service';
+import {
+ CatalogLabelService,
+ CatalogLocale,
+ normalizeCatalogLocale,
+} from '../catalog/catalog-label.service';
+
+export type ProsthesisTypeCatalogEntry = {
+ code: string;
+ sortOrder: number;
+ label: string;
+};
+
+@Injectable()
+export class ProsthesisCatalogService implements OnModuleInit {
+ private loaded = false;
+ private byCode = new Map();
+
+ constructor(
+ private readonly prisma: PrismaService,
+ private readonly catalogLabels: CatalogLabelService,
+ ) {}
+
+ async onModuleInit() {
+ await this.refresh();
+ }
+
+ async refresh(): Promise {
+ const rows = await this.prisma.prosthesisType.findMany({
+ where: { isActive: true },
+ orderBy: [{ sortOrder: 'asc' }, { code: 'asc' }],
+ select: { code: true, sortOrder: true },
+ });
+
+ this.byCode = new Map(rows.map((row) => [row.code, { sortOrder: row.sortOrder }]));
+ this.loaded = true;
+ }
+
+ async list(localeInput?: string | null): Promise {
+ this.ensureLoaded();
+ const locale = normalizeCatalogLocale(localeInput);
+ const codes = [...this.byCode.keys()];
+ const labels = await this.catalogLabels.resolveLabels(
+ CatalogEntityKind.PROSTHESIS_TYPE,
+ codes,
+ locale,
+ );
+
+ return codes
+ .map((code) => ({
+ code,
+ sortOrder: this.byCode.get(code)!.sortOrder,
+ label: labels.get(code) ?? code,
+ }))
+ .sort((a, b) => a.sortOrder - b.sortOrder || a.code.localeCompare(b.code));
+ }
+
+ assertKnownProsthesisType(code: string): void {
+ this.ensureLoaded();
+ if (!this.byCode.has(code)) {
+ throw new BadRequestException(`Unknown prosthesis type: ${code}`);
+ }
+ }
+
+ async getStepCodesForProsthesisType(prosthesisTypeCode: string): Promise {
+ const type = await this.prisma.prosthesisType.findUnique({
+ where: { code: prosthesisTypeCode },
+ select: {
+ steps: {
+ orderBy: { stepOrder: 'asc' },
+ select: { labWorkflowStep: { select: { code: true } } },
+ },
+ },
+ });
+
+ if (!type) {
+ return [];
+ }
+
+ return type.steps.map((s) => s.labWorkflowStep.code);
+ }
+
+ async resolveStepLabels(stepCodes: string[], locale: CatalogLocale): Promise> {
+ return this.catalogLabels.resolveLabels(
+ CatalogEntityKind.LAB_WORKFLOW_STEP,
+ stepCodes,
+ locale,
+ );
+ }
+
+ private ensureLoaded() {
+ if (!this.loaded) {
+ throw new Error('Prosthesis catalog is not loaded yet');
+ }
+ }
+}
diff --git a/backend/src/modules/tasks/tasks.controller.ts b/backend/src/modules/tasks/tasks.controller.ts
index a2125e4..0cce614 100644
--- a/backend/src/modules/tasks/tasks.controller.ts
+++ b/backend/src/modules/tasks/tasks.controller.ts
@@ -16,7 +16,7 @@ export class TasksController {
@ApiOperation({ summary: 'List lab tasks (owner: all, staff: assigned only)' })
list(@Query() query: ListLabTasksDto, @Req() req) {
const organizationId = this.tasksService.getOrganizationIdFromUser(req.user);
- return this.tasksService.list(organizationId, req.user.id, query);
+ return this.tasksService.list(organizationId, req.user.id, query, req.user.language);
}
@Patch(':taskId')
@@ -27,6 +27,12 @@ export class TasksController {
@Req() req,
) {
const organizationId = this.tasksService.getOrganizationIdFromUser(req.user);
- return this.tasksService.updateStatus(taskId, dto, organizationId, req.user.id);
+ return this.tasksService.updateStatus(
+ taskId,
+ dto,
+ organizationId,
+ req.user.id,
+ req.user.language,
+ );
}
}
diff --git a/backend/src/modules/tasks/tasks.service.ts b/backend/src/modules/tasks/tasks.service.ts
index ce7872a..66fd7c4 100644
--- a/backend/src/modules/tasks/tasks.service.ts
+++ b/backend/src/modules/tasks/tasks.service.ts
@@ -4,8 +4,12 @@ import {
Injectable,
NotFoundException,
} from '@nestjs/common';
-import { LabTaskStatus, Prisma } from '@prisma/client';
+import { CatalogEntityKind, LabTaskStatus, Prisma } from '@prisma/client';
import { PrismaService } from '../../../prisma/prisma.service';
+import {
+ CatalogLabelService,
+ normalizeCatalogLocale,
+} from '../catalog/catalog-label.service';
import { ListLabTasksDto, UpdateLabTaskDto } from './dto/tasks.dto';
const taskListInclude = {
@@ -24,7 +28,10 @@ const taskListInclude = {
@Injectable()
export class TasksService {
- constructor(private readonly prisma: PrismaService) {}
+ constructor(
+ private readonly prisma: PrismaService,
+ private readonly catalogLabels: CatalogLabelService,
+ ) {}
getOrganizationIdFromUser(user: { organizationId?: string }) {
if (!user?.organizationId) {
@@ -33,7 +40,12 @@ export class TasksService {
return user.organizationId;
}
- async list(labOrganizationId: string, actorUserId: string, query: ListLabTasksDto) {
+ async list(
+ labOrganizationId: string,
+ actorUserId: string,
+ query: ListLabTasksDto,
+ localeInput?: string | null,
+ ) {
await this.assertCanReadTasks(actorUserId, labOrganizationId);
const membership = await this.getMembership(actorUserId, labOrganizationId);
@@ -71,10 +83,18 @@ export class TasksService {
this.prisma.labCaseTask.count({ where }),
]);
+ const locale = normalizeCatalogLocale(localeInput);
+ const prosthesisCodes = [...new Set(items.map((t) => t.prosthesisTypeCode).filter(Boolean))];
+ const prosthesisLabels = await this.catalogLabels.resolveLabels(
+ CatalogEntityKind.PROSTHESIS_TYPE,
+ prosthesisCodes,
+ locale,
+ );
+
return {
success: true,
data: {
- items: items.map((task) => this.mapTaskListItem(task)),
+ items: items.map((task) => this.mapTaskListItem(task, prosthesisLabels)),
pagination: {
page,
limit,
@@ -90,6 +110,7 @@ export class TasksService {
dto: UpdateLabTaskDto,
labOrganizationId: string,
actorUserId: string,
+ localeInput?: string | null,
) {
await this.assertCanEditTasks(actorUserId, labOrganizationId);
@@ -123,17 +144,28 @@ export class TasksService {
include: taskListInclude,
});
- return { success: true, data: this.mapTaskListItem(updated) };
+ const locale = normalizeCatalogLocale(localeInput);
+ const prosthesisLabels = await this.catalogLabels.resolveLabels(
+ CatalogEntityKind.PROSTHESIS_TYPE,
+ [updated.prosthesisTypeCode],
+ locale,
+ );
+
+ return { success: true, data: this.mapTaskListItem(updated, prosthesisLabels) };
}
private mapTaskListItem(
task: Prisma.LabCaseTaskGetPayload<{ include: typeof taskListInclude }>,
+ prosthesisLabels: Map,
) {
return {
id: task.id,
labCaseId: task.labCaseId,
tooth: task.tooth,
treatmentType: task.treatmentType,
+ prosthesisTypeCode: task.prosthesisTypeCode,
+ prosthesisTypeLabel:
+ prosthesisLabels.get(task.prosthesisTypeCode) ?? task.prosthesisTypeCode,
stepOrder: task.stepOrder,
stepLabel: task.stepLabel,
status: task.status,
diff --git a/backend/src/modules/treatment-catalog/treatment-catalog.controller.ts b/backend/src/modules/treatment-catalog/treatment-catalog.controller.ts
index 7e26db5..062a603 100644
--- a/backend/src/modules/treatment-catalog/treatment-catalog.controller.ts
+++ b/backend/src/modules/treatment-catalog/treatment-catalog.controller.ts
@@ -1,4 +1,4 @@
-import { Controller, Get, UseGuards } from '@nestjs/common';
+import { Controller, Get, Req, 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';
@@ -11,11 +11,9 @@ 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(),
- };
+ @ApiOperation({ summary: 'List active treatment types with localized labels' })
+ async list(@Req() req: { user?: { language?: string | null } }) {
+ const data = await this.treatmentCatalogService.list(req.user?.language);
+ return { success: true, data };
}
}
diff --git a/backend/src/modules/treatment-catalog/treatment-catalog.service.ts b/backend/src/modules/treatment-catalog/treatment-catalog.service.ts
index e6c8c9e..7baf921 100644
--- a/backend/src/modules/treatment-catalog/treatment-catalog.service.ts
+++ b/backend/src/modules/treatment-catalog/treatment-catalog.service.ts
@@ -1,11 +1,18 @@
-import { BadRequestException, Injectable, OnModuleInit } from '@nestjs/common';
+import { Injectable, OnModuleInit } from '@nestjs/common';
+import { BadRequestException } from '@nestjs/common';
+import { CatalogEntityKind } from '@prisma/client';
import { PrismaService } from '../../../prisma/prisma.service';
+import {
+ CatalogLabelService,
+ normalizeCatalogLocale,
+} from '../catalog/catalog-label.service';
export type TreatmentTypeCatalogEntry = {
id: string;
code: string;
labDependent: boolean;
sortOrder: number;
+ label: string;
};
@Injectable()
@@ -13,7 +20,10 @@ export class TreatmentCatalogService implements OnModuleInit {
private loaded = false;
private byCode = new Map();
- constructor(private readonly prisma: PrismaService) {}
+ constructor(
+ private readonly prisma: PrismaService,
+ private readonly catalogLabels: CatalogLabelService,
+ ) {}
async onModuleInit() {
await this.refresh();
@@ -21,17 +31,46 @@ export class TreatmentCatalogService implements OnModuleInit {
async refresh(): Promise {
const rows = await this.prisma.treatmentType.findMany({
+ where: { isActive: true },
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.byCode = new Map(
+ rows.map((row) => [
+ row.code,
+ {
+ id: row.id,
+ code: row.code,
+ labDependent: row.labDependent,
+ sortOrder: row.sortOrder,
+ label: row.code,
+ },
+ ]),
+ );
this.loaded = true;
}
- list(): TreatmentTypeCatalogEntry[] {
+ async list(localeInput?: string | null): Promise {
+ await this.ensureLabels(localeInput);
+ return [...this.byCode.values()].sort(
+ (a, b) => a.sortOrder - b.sortOrder || a.code.localeCompare(b.code),
+ );
+ }
+
+ private async ensureLabels(localeInput?: string | null) {
this.ensureLoaded();
- return [...this.byCode.values()];
+ const locale = normalizeCatalogLocale(localeInput);
+ const codes = [...this.byCode.keys()];
+ const labels = await this.catalogLabels.resolveLabels(
+ CatalogEntityKind.TREATMENT_TYPE,
+ codes,
+ locale,
+ );
+
+ for (const [code, entry] of this.byCode) {
+ entry.label = labels.get(code) ?? entry.code;
+ }
}
getByCode(code: string): TreatmentTypeCatalogEntry | undefined {
diff --git a/backend/src/modules/treatments/dto/treatment.dto.ts b/backend/src/modules/treatments/dto/treatment.dto.ts
index 3c0312e..0b78f62 100644
--- a/backend/src/modules/treatments/dto/treatment.dto.ts
+++ b/backend/src/modules/treatments/dto/treatment.dto.ts
@@ -45,6 +45,19 @@ export class SaveTreatmentDraftDto {
details: SaveTreatmentDetailDto[];
}
+export class LabCaseToothProsthesisDto {
+ @IsUUID()
+ treatmentDetailId: string;
+
+ @IsString()
+ @MaxLength(8)
+ tooth: string;
+
+ @IsString()
+ @MaxLength(64)
+ prosthesisTypeCode: string;
+}
+
export class SaveLabCaseDto {
@IsString()
@MaxLength(64)
@@ -67,6 +80,12 @@ export class SaveLabCaseDto {
@ArrayMinSize(1)
@IsUUID(undefined, { each: true })
treatmentDetailIds: string[];
+
+ @IsOptional()
+ @IsArray()
+ @ValidateNested({ each: true })
+ @Type(() => LabCaseToothProsthesisDto)
+ toothProsthesis?: LabCaseToothProsthesisDto[];
}
export class SaveTreatmentLabCasesDto {
diff --git a/backend/src/modules/treatments/lab-case-send.validation.spec.ts b/backend/src/modules/treatments/lab-case-send.validation.spec.ts
new file mode 100644
index 0000000..3bab06e
--- /dev/null
+++ b/backend/src/modules/treatments/lab-case-send.validation.spec.ts
@@ -0,0 +1,52 @@
+import { assertCompleteToothProsthesisMap } from './lab-case-send.validation';
+
+describe('assertCompleteToothProsthesisMap', () => {
+ const prosthesisDetailId = 'detail-1';
+
+ it('passes when every prosthesis tooth has a mapping', () => {
+ expect(() =>
+ assertCompleteToothProsthesisMap({
+ details: [
+ {
+ treatmentDetailId: prosthesisDetailId,
+ detail: { id: prosthesisDetailId, treatmentType: 'prosthesis', teeth: ['14', '15'] },
+ },
+ ],
+ toothProsthesis: [
+ { treatmentDetailId: prosthesisDetailId, tooth: '14', prosthesisTypeCode: 'pfm_crown' },
+ { treatmentDetailId: prosthesisDetailId, tooth: '15', prosthesisTypeCode: 'pfm_crown' },
+ ],
+ }),
+ ).not.toThrow();
+ });
+
+ it('ignores non-prosthesis details', () => {
+ expect(() =>
+ assertCompleteToothProsthesisMap({
+ details: [
+ {
+ treatmentDetailId: 'endo-1',
+ detail: { id: 'endo-1', treatmentType: 'endo', teeth: ['36'] },
+ },
+ ],
+ toothProsthesis: [],
+ }),
+ ).not.toThrow();
+ });
+
+ it('throws when a prosthesis tooth is missing from the map', () => {
+ expect(() =>
+ assertCompleteToothProsthesisMap({
+ details: [
+ {
+ treatmentDetailId: prosthesisDetailId,
+ detail: { id: prosthesisDetailId, treatmentType: 'prosthesis', teeth: ['14', '15'] },
+ },
+ ],
+ toothProsthesis: [
+ { treatmentDetailId: prosthesisDetailId, tooth: '14', prosthesisTypeCode: 'pfm_crown' },
+ ],
+ }),
+ ).toThrow('missing tooth 15');
+ });
+});
diff --git a/backend/src/modules/treatments/lab-case-send.validation.ts b/backend/src/modules/treatments/lab-case-send.validation.ts
new file mode 100644
index 0000000..4a28382
--- /dev/null
+++ b/backend/src/modules/treatments/lab-case-send.validation.ts
@@ -0,0 +1,37 @@
+import { BadRequestException } from '@nestjs/common';
+import { normalizeTeeth } from './treatment.utils';
+
+export type LabCaseProsthesisLink = {
+ treatmentDetailId: string;
+ detail: { id: string; treatmentType: string; teeth: unknown };
+};
+
+export type LabCaseToothProsthesisRow = {
+ treatmentDetailId: string;
+ tooth: string;
+ prosthesisTypeCode: string;
+};
+
+export function assertCompleteToothProsthesisMap(labCase: {
+ details: LabCaseProsthesisLink[];
+ toothProsthesis: LabCaseToothProsthesisRow[];
+}) {
+ const prosthesisByKey = new Set(
+ labCase.toothProsthesis.map((tp) => `${tp.treatmentDetailId}:${tp.tooth}`),
+ );
+
+ for (const link of labCase.details) {
+ if (link.detail.treatmentType !== 'prosthesis') {
+ continue;
+ }
+ const teeth = normalizeTeeth(link.detail.teeth);
+ for (const tooth of teeth) {
+ const key = `${link.treatmentDetailId}:${tooth}`;
+ if (!prosthesisByKey.has(key)) {
+ throw new BadRequestException(
+ `Each tooth must have a prosthesis type before sending (missing tooth ${tooth})`,
+ );
+ }
+ }
+ }
+}
diff --git a/backend/src/modules/treatments/treatments.controller.ts b/backend/src/modules/treatments/treatments.controller.ts
index 8a9eb85..da02dcb 100644
--- a/backend/src/modules/treatments/treatments.controller.ts
+++ b/backend/src/modules/treatments/treatments.controller.ts
@@ -184,9 +184,14 @@ export class TreatmentsController {
@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 } },
+ @Req() req: { user: { id: string; organizationId?: string; language?: string | null } },
) {
const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user);
- return this.treatmentsService.sendLabCase(labCaseId, organizationId, req.user.id);
+ return this.treatmentsService.sendLabCase(
+ labCaseId,
+ organizationId,
+ req.user.id,
+ req.user.language,
+ );
}
}
diff --git a/backend/src/modules/treatments/treatments.module.ts b/backend/src/modules/treatments/treatments.module.ts
index 47646b1..a587c2d 100644
--- a/backend/src/modules/treatments/treatments.module.ts
+++ b/backend/src/modules/treatments/treatments.module.ts
@@ -1,10 +1,12 @@
import { Module } from '@nestjs/common';
import { PrismaService } from '../../../prisma/prisma.service';
import { ClinicOrgGuard } from '../../common/guards/clinic-org.guard';
+import { ProsthesisCatalogModule } from '../prosthesis-catalog/prosthesis-catalog.module';
import { TreatmentsController } from './treatments.controller';
import { TreatmentsService } from './treatments.service';
@Module({
+ imports: [ProsthesisCatalogModule],
controllers: [TreatmentsController],
providers: [TreatmentsService, PrismaService, ClinicOrgGuard],
})
diff --git a/backend/src/modules/treatments/treatments.service.ts b/backend/src/modules/treatments/treatments.service.ts
index 51e96a7..e2aed47 100644
--- a/backend/src/modules/treatments/treatments.service.ts
+++ b/backend/src/modules/treatments/treatments.service.ts
@@ -10,6 +10,7 @@ import { join } from 'path';
import { randomUUID } from 'crypto';
import { PrismaService } from '../../../prisma/prisma.service';
import { generateLabCaseTasks } from '../cases/lab-case-task.generator';
+import { ProsthesisCatalogService } from '../prosthesis-catalog/prosthesis-catalog.service';
import { TreatmentCatalogService } from '../treatment-catalog/treatment-catalog.service';
import {
SaveTreatmentDraftDto,
@@ -19,6 +20,7 @@ import {
generateTreatmentTitle,
normalizeTeeth,
} from './treatment.utils';
+import { assertCompleteToothProsthesisMap } from './lab-case-send.validation';
const treatmentInclude = {
details: {
@@ -53,6 +55,7 @@ const treatmentInclude = {
orderBy: [{ sentAt: 'asc' as const }],
include: { organization: { select: { id: true, name: true } } },
},
+ toothProsthesis: true,
},
},
};
@@ -64,6 +67,7 @@ export class TreatmentsService {
constructor(
private readonly prisma: PrismaService,
private readonly treatmentCatalog: TreatmentCatalogService,
+ private readonly prosthesisCatalog: ProsthesisCatalogService,
) {}
getOrganizationIdFromUser(user: { organizationId?: string }) {
@@ -326,7 +330,7 @@ export class TreatmentsService {
const details = await this.prisma.treatmentDetail.findMany({
where: { treatmentId: treatment.id, id: { in: detailIds } },
- select: { id: true, treatmentType: true },
+ select: { id: true, treatmentType: true, teeth: true },
});
if (details.length !== uniqueDetailIds.size) {
throw new BadRequestException('One or more treatment details were not found');
@@ -336,12 +340,30 @@ export class TreatmentsService {
this.treatmentCatalog.assertLabDependentTreatmentType(detail.treatmentType);
}
+ const detailById = new Map(details.map((d) => [d.id, d]));
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');
}
+
+ for (const row of lc.toothProsthesis ?? []) {
+ if (!lc.treatmentDetailIds.includes(row.treatmentDetailId)) {
+ throw new BadRequestException(
+ 'Tooth prosthesis must reference a detail included in this lab case',
+ );
+ }
+ const detail = detailById.get(row.treatmentDetailId);
+ if (!detail) {
+ throw new BadRequestException('Tooth prosthesis references an unknown treatment detail');
+ }
+ const teeth = normalizeTeeth(detail.teeth);
+ if (!teeth.includes(row.tooth)) {
+ throw new BadRequestException(`Tooth ${row.tooth} is not on the selected treatment detail`);
+ }
+ this.prosthesisCatalog.assertKnownProsthesisType(row.prosthesisTypeCode);
+ }
}
const saved = await this.prisma.$transaction(async (tx) => {
@@ -395,6 +417,18 @@ export class TreatmentsService {
treatmentDetailId,
})),
});
+
+ await tx.labCaseToothProsthesis.deleteMany({ where: { labCaseId: row.id } });
+ if (lc.toothProsthesis?.length) {
+ await tx.labCaseToothProsthesis.createMany({
+ data: lc.toothProsthesis.map((tp) => ({
+ labCaseId: row.id,
+ treatmentDetailId: tp.treatmentDetailId,
+ tooth: tp.tooth,
+ prosthesisTypeCode: tp.prosthesisTypeCode,
+ })),
+ });
+ }
}
return tx.treatment.findUniqueOrThrow({
@@ -410,6 +444,7 @@ export class TreatmentsService {
labCaseId: string,
organizationId: string,
actorUserId: string,
+ actorLanguage?: string | null,
) {
await this.assertCanEditTreatment(actorUserId, organizationId);
@@ -421,7 +456,12 @@ export class TreatmentsService {
include: {
treatment: { select: { providerUserId: true } },
sends: { select: { organizationId: true } },
- details: { select: { treatmentDetailId: true } },
+ details: {
+ include: {
+ detail: { select: { id: true, treatmentType: true, teeth: true } },
+ },
+ },
+ toothProsthesis: true,
},
});
@@ -437,6 +477,8 @@ export class TreatmentsService {
throw new BadRequestException('Lab case must include at least one treatment detail');
}
+ assertCompleteToothProsthesisMap(labCase);
+
if (labCase.treatment.providerUserId !== actorUserId) {
const membership = await this.getMembership(actorUserId, organizationId);
if (!membership?.isOwner) {
@@ -473,7 +515,7 @@ export class TreatmentsService {
});
}
- await generateLabCaseTasks(tx, labCaseId);
+ await generateLabCaseTasks(tx, labCaseId, actorLanguage);
});
const refreshed = await this.prisma.labCase.findUniqueOrThrow({
@@ -490,6 +532,7 @@ export class TreatmentsService {
orderBy: [{ sentAt: 'asc' }],
include: { organization: { select: { id: true, name: true } } },
},
+ toothProsthesis: true,
},
});
@@ -722,6 +765,11 @@ export class TreatmentsService {
sentAt: Date;
organization?: { id: string; name: string };
}>;
+ toothProsthesis?: Array<{
+ treatmentDetailId: string;
+ tooth: string;
+ prosthesisTypeCode: string;
+ }>;
}) {
return {
id: lc.id,
@@ -736,6 +784,11 @@ export class TreatmentsService {
treatmentType: d.detail?.treatmentType ?? '',
teeth: d.detail ? normalizeTeeth(d.detail.teeth) : [],
})),
+ toothProsthesis: (lc.toothProsthesis ?? []).map((tp) => ({
+ treatmentDetailId: tp.treatmentDetailId,
+ tooth: tp.tooth,
+ prosthesisTypeCode: tp.prosthesisTypeCode,
+ })),
sends:
lc.sends?.map((s) => ({
organizationId: s.organizationId,
diff --git a/frontend/messages/en.json b/frontend/messages/en.json
index a8fcaab..93c9679 100644
--- a/frontend/messages/en.json
+++ b/frontend/messages/en.json
@@ -330,7 +330,7 @@
"treatmentDetails": "Treatment details",
"teethLabel": "Teeth",
"tasksByTooth": "Tasks by tooth",
- "toothGroupTitle": "Tooth {tooth} · {type}",
+ "toothGroupTitle": "Tooth {tooth} · {prosthesis} · {type}",
"noTasks": "No tasks were generated for this case.",
"unassigned": "Unassigned",
"statusPending": "Pending",
@@ -497,8 +497,14 @@
"labShipmentNoIncludedDetails": "No details were included in this shipment.",
"labShipmentNoDetailsAvailable": "All lab details are already in other shipments or have been sent.",
"labDetailLine": "Detail {n} · {type} · {teeth}",
- "noLabDetails": "No lab-dependent treatment details yet. Add a lab type (e.g. endo) in treatment details above.",
+ "noLabDetails": "No prosthesis treatment details yet. Add prosthesis in treatment details above.",
"labDispatchEmpty": "Add a lab shipment to group details and send them to a lab.",
+ "prosthesisTypesTitle": "Prosthesis types",
+ "prosthesisApplyAll": "Apply to all teeth",
+ "prosthesisSelectPlaceholder": "Select prosthesis type…",
+ "prosthesisColTooth": "Tooth",
+ "prosthesisColDetail": "Detail",
+ "prosthesisColType": "Prosthesis type",
"labComment": "Message for the lab",
"labCommentPlaceholder": "Optional instructions for this shipment…",
"selectLab": "Destination lab",
diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json
index 36e2021..388c300 100644
--- a/frontend/messages/fa.json
+++ b/frontend/messages/fa.json
@@ -330,7 +330,7 @@
"treatmentDetails": "جزئیات درمان",
"teethLabel": "دندانها",
"tasksByTooth": "وظایف به تفکیک دندان",
- "toothGroupTitle": "دندان {tooth} · {type}",
+ "toothGroupTitle": "دندان {tooth} · {prosthesis} · {type}",
"noTasks": "برای این پرونده وظیفهای ایجاد نشده است.",
"unassigned": "بدون مسئول",
"statusPending": "در انتظار",
@@ -497,8 +497,14 @@
"labShipmentNoIncludedDetails": "جزئیاتی در این محموله گنجانده نشده است.",
"labShipmentNoDetailsAvailable": "همه جزئیات لاب در محمولههای دیگر هستند یا ارسال شدهاند.",
"labDetailLine": "جزئیات {n} · {type} · {teeth}",
- "noLabDetails": "هنوز جزئیات وابسته به لاب وجود ندارد. نوع لاب (مثلاً اندو) در جزئیات درمان بالا اضافه کنید.",
+ "noLabDetails": "هنوز جزئیات پروتز وجود ندارد. پروتز را در جزئیات درمان بالا اضافه کنید.",
"labDispatchEmpty": "یک محموله لاب اضافه کنید تا جزئیات را گروهبندی و ارسال کنید.",
+ "prosthesisTypesTitle": "انواع پروتز",
+ "prosthesisApplyAll": "اعمال برای همه دندانها",
+ "prosthesisSelectPlaceholder": "نوع پروتز را انتخاب کنید…",
+ "prosthesisColTooth": "دندان",
+ "prosthesisColDetail": "جزئیات",
+ "prosthesisColType": "نوع پروتز",
"labComment": "پیام برای لابراتوار",
"labCommentPlaceholder": "دستورالعمل اختیاری برای این محموله…",
"selectLab": "لابراتوار مقصد",
diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json
index 0097a5a..ab69c7a 100644
--- a/frontend/messages/nl.json
+++ b/frontend/messages/nl.json
@@ -330,7 +330,7 @@
"treatmentDetails": "Behandeldetails",
"teethLabel": "Tanden",
"tasksByTooth": "Taken per tand",
- "toothGroupTitle": "Tand {tooth} · {type}",
+ "toothGroupTitle": "Tand {tooth} · {prosthesis} · {type}",
"noTasks": "Er zijn geen taken gegenereerd voor dit dossier.",
"unassigned": "Niet toegewezen",
"statusPending": "In afwachting",
@@ -497,8 +497,14 @@
"labShipmentNoIncludedDetails": "Geen details opgenomen in deze zending.",
"labShipmentNoDetailsAvailable": "Alle labdetails zitten al in andere zendingen of zijn verzonden.",
"labDetailLine": "Detail {n} · {type} · {teeth}",
- "noLabDetails": "Nog geen lab-afhankelijke details. Voeg een labtype (bijv. endo) toe in de behandeldetails hierboven.",
+ "noLabDetails": "Nog geen prothese-details. Voeg prothese toe in de behandeldetails hierboven.",
"labDispatchEmpty": "Voeg een labzending toe om details te groeperen en naar een lab te sturen.",
+ "prosthesisTypesTitle": "Prothesetypes",
+ "prosthesisApplyAll": "Toepassen op alle tanden",
+ "prosthesisSelectPlaceholder": "Selecteer prothesetype…",
+ "prosthesisColTooth": "Tand",
+ "prosthesisColDetail": "Detail",
+ "prosthesisColType": "Prothesetype",
"labComment": "Bericht voor het lab",
"labCommentPlaceholder": "Optionele instructies voor deze zending…",
"selectLab": "Bestemmingslab",
diff --git a/frontend/src/app/[locale]/(dashboard)/cases/page.tsx b/frontend/src/app/[locale]/(dashboard)/cases/page.tsx
index 9dddf11..31bf060 100644
--- a/frontend/src/app/[locale]/(dashboard)/cases/page.tsx
+++ b/frontend/src/app/[locale]/(dashboard)/cases/page.tsx
@@ -10,9 +10,12 @@ import { useToast } from '@/lib/hooks/useToast';
import { canEditCases } from '@/components/shared/permissions';
import { Badge, type BadgeVariant } from '@/components/ui/shared/Badge';
import { casesApi } from '@/lib/api/cases';
+import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
+import { treatmentTypeLabelFromCatalog } from '@/components/ui/treatment/treatmentTypeDisplay';
import { Button } from '@/components/ui/shared/Button';
import { FORM_SELECT_CLASS } from '@/components/ui/shared/formSelectStyles';
import { SearchBar } from '@/components/ui/shared/SearchBar';
+import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
import type {
AssignableMember,
CasesFilterOptions,
@@ -22,14 +25,6 @@ import type {
PaginatedLabCases,
} from '@/types/cases';
-const TREATMENT_TYPE_KEYS = {
- consultation: 'typeConsultation',
- filling: 'typeFilling',
- endo: 'typeEndo',
- visit: 'typeVisit',
- hygiene: 'typeHygiene',
-} as const;
-
const PAGE_SIZE = 20;
const PRIORITY_OPTIONS = [1, 2, 3, 4, 5] as const;
@@ -101,6 +96,7 @@ export default function CasesPage() {
clinics: [],
treatmentTypes: [],
});
+ const [treatmentCatalog, setTreatmentCatalog] = useState([]);
const [selectedCaseId, setSelectedCaseId] = useState(null);
const [selectedCase, setSelectedCase] = useState(null);
@@ -113,11 +109,8 @@ export default function CasesPage() {
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],
+ (type: string) => treatmentTypeLabelFromCatalog(type, treatmentCatalog),
+ [treatmentCatalog],
);
const statusOptions: { value: LabTaskStatus; label: string }[] = useMemo(
@@ -179,6 +172,7 @@ export default function CasesPage() {
useEffect(() => {
void casesApi.listFilterOptions().then((r) => setFilterOptions(r.data)).catch(() => {});
void casesApi.listAssignableMembers().then((r) => setMembers(r.data)).catch(() => {});
+ void treatmentCatalogApi.list().then((r) => setTreatmentCatalog(r.data)).catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only initial fetch
}, []);
@@ -490,6 +484,7 @@ export default function CasesPage() {
{t('toothGroupTitle', {
tooth: group.tooth,
+ prosthesis: group.prosthesisTypeLabel,
type: treatmentLabel(group.treatmentType),
})}
diff --git a/frontend/src/app/[locale]/(dashboard)/tasks/page.tsx b/frontend/src/app/[locale]/(dashboard)/tasks/page.tsx
index 775321c..9ef5763 100644
--- a/frontend/src/app/[locale]/(dashboard)/tasks/page.tsx
+++ b/frontend/src/app/[locale]/(dashboard)/tasks/page.tsx
@@ -12,7 +12,10 @@ import { canEditTasks, canViewTasks } from '@/components/shared/permissions';
import { useAuth } from '@/lib/hooks/useAuth';
import { useToast } from '@/lib/hooks/useToast';
import { tasksApi } from '@/lib/api/tasks';
+import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
+import { treatmentTypeLabelFromCatalog } from '@/components/ui/treatment/treatmentTypeDisplay';
import type { LabTaskListItem, LabTaskStatus, PaginatedLabTasks } from '@/types/cases';
+import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
const PAGE_SIZE = 50;
@@ -46,6 +49,7 @@ export default function TasksPage() {
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [updatingTaskId, setUpdatingTaskId] = useState(null);
+ const [treatmentCatalog, setTreatmentCatalog] = useState([]);
const canView = canViewTasks(currentOrganization);
const canEdit = canEditTasks(currentOrganization);
@@ -64,6 +68,10 @@ export default function TasksPage() {
[t],
);
+ useEffect(() => {
+ void treatmentCatalogApi.list().then((r) => setTreatmentCatalog(r.data)).catch(() => {});
+ }, []);
+
useEffect(() => {
if (!canView) return;
@@ -166,6 +174,7 @@ export default function TasksPage() {
{t('fromClinic', { name: task.clinic.name })} ·{' '}
{formatPatientName(task.patient)} · {t('toothLabel', { tooth: task.tooth })}
+ {task.prosthesisTypeLabel ? ` · ${task.prosthesisTypeLabel}` : ''}
{t('taskDate', { date: formatTaskDate(sortDateForTask(task)) })}
@@ -213,7 +222,10 @@ export default function TasksPage() {
{t('priorityLabel', { n: task.priority })}
-
+
);
diff --git a/frontend/src/components/ui/organizations/ConnectionCaseHistoryContent.tsx b/frontend/src/components/ui/organizations/ConnectionCaseHistoryContent.tsx
index b95dc33..a148755 100644
--- a/frontend/src/components/ui/organizations/ConnectionCaseHistoryContent.tsx
+++ b/frontend/src/components/ui/organizations/ConnectionCaseHistoryContent.tsx
@@ -6,20 +6,15 @@ import { formatApiErrorMessage } from '@/components/shared/formatApiError';
import { useAuth } from '@/lib/hooks/useAuth';
import { useToast } from '@/lib/hooks/useToast';
import { organizationApi } from '@/lib/api/organization';
+import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
+import { treatmentTypeLabelFromCatalog } from '@/components/ui/treatment/treatmentTypeDisplay';
import { Badge, type BadgeVariant } from '@/components/ui/shared/Badge';
import { Button } from '@/components/ui/shared/Button';
import { SearchBar } from '@/components/ui/shared/SearchBar';
import { ToastStack } from '@/components/ui/shared/Toast';
import type { CounterpartItemDto } from '@/lib/api/organization';
import type { LabCaseDetail, LabCaseListItem, LabTaskStatus } from '@/types/cases';
-
-const TREATMENT_TYPE_KEYS = {
- consultation: 'typeConsultation',
- filling: 'typeFilling',
- endo: 'typeEndo',
- visit: 'typeVisit',
- hygiene: 'typeHygiene',
-} as const;
+import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
const PAGE_SIZE = 20;
@@ -78,7 +73,6 @@ export function ConnectionCaseHistoryContent({
}: ConnectionCaseHistoryContentProps) {
const t = useTranslations('organizations');
const tCases = useTranslations('cases');
- const tTreatment = useTranslations('treatment');
const tCommon = useTranslations('common');
const { currentOrganization, user } = useAuth();
const { showError, setError, messages: toastMessages } = useToast();
@@ -94,6 +88,7 @@ export function ConnectionCaseHistoryContent({
});
const [selectedCaseId, setSelectedCaseId] = useState
(null);
const [selectedCase, setSelectedCase] = useState(null);
+ const [treatmentCatalog, setTreatmentCatalog] = useState([]);
const [loadingList, setLoadingList] = useState(false);
const [loadingDetail, setLoadingDetail] = useState(false);
@@ -104,13 +99,14 @@ export function ConnectionCaseHistoryContent({
tRef.current = t;
const treatmentLabel = useCallback(
- (type: string) => {
- const key = TREATMENT_TYPE_KEYS[type as keyof typeof TREATMENT_TYPE_KEYS];
- return key ? tTreatment(key) : type;
- },
- [tTreatment],
+ (type: string) => treatmentTypeLabelFromCatalog(type, treatmentCatalog),
+ [treatmentCatalog],
);
+ useEffect(() => {
+ void treatmentCatalogApi.list().then((r) => setTreatmentCatalog(r.data)).catch(() => {});
+ }, []);
+
const statusOptions: { value: LabTaskStatus; label: string }[] = useMemo(
() => [
{ value: 'PENDING', label: tCases('statusPending') },
@@ -375,6 +371,7 @@ export function ConnectionCaseHistoryContent({
{tCases('toothGroupTitle', {
tooth: group.tooth,
+ prosthesis: group.prosthesisTypeLabel,
type: treatmentLabel(group.treatmentType),
})}
diff --git a/frontend/src/components/ui/treatment/AppointmentsStrip.tsx b/frontend/src/components/ui/treatment/AppointmentsStrip.tsx
index 20f4f29..527b9ee 100644
--- a/frontend/src/components/ui/treatment/AppointmentsStrip.tsx
+++ b/frontend/src/components/ui/treatment/AppointmentsStrip.tsx
@@ -6,16 +6,10 @@ import { purposeStyle } from '@/components/ui/appointments/appointmentPurposeSty
import { Card } from '@/components/ui/shared/Card';
import { ScheduleDayPicker } from '@/components/ui/shared/ScheduleDayPicker';
import { startOfLocalDay } from '@/components/appointments/appointmentTime';
+import { treatmentTypeLabelFromCatalog } from '@/components/ui/treatment/treatmentTypeDisplay';
+import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
import type { TreatmentAppointment } from '@/types/treatment';
-const TREATMENT_TYPE_KEYS = {
- consultation: 'typeConsultation',
- filling: 'typeFilling',
- endo: 'typeEndo',
- visit: 'typeVisit',
- hygiene: 'typeHygiene',
-} as const;
-
interface AppointmentsStripProps {
stripHidden: boolean;
onToggleStripHidden: () => void;
@@ -24,6 +18,7 @@ interface AppointmentsStripProps {
appointments: TreatmentAppointment[];
selectedAppointmentId: string | null;
onSelectAppointment: (id: string) => void;
+ treatmentCatalog: TreatmentCatalogEntry[];
loading?: boolean;
}
@@ -35,6 +30,7 @@ export function AppointmentsStrip({
appointments,
selectedAppointmentId,
onSelectAppointment,
+ treatmentCatalog,
loading = false,
}: AppointmentsStripProps) {
const t = useTranslations('treatment');
@@ -96,7 +92,7 @@ export function AppointmentsStrip({
minute: '2-digit',
})}`;
const palette = purposeStyle(a.purpose);
- const purposeKey = TREATMENT_TYPE_KEYS[a.purpose as keyof typeof TREATMENT_TYPE_KEYS];
+ const purposeLabel = treatmentTypeLabelFromCatalog(a.purpose, treatmentCatalog);
return (
{a.patientFirstName} {a.patientLastName}
-
- {purposeKey ? t(purposeKey) : a.purpose}
-
+ {purposeLabel}
);
})}
diff --git a/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx b/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx
index a2ab79c..4b2965a 100644
--- a/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx
+++ b/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx
@@ -1,20 +1,24 @@
'use client';
-import { useMemo } from 'react';
+import { useEffect, useMemo, useState } from 'react';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/shared/Button';
import { Checkbox } from '@/components/ui/shared/Checkbox';
import { Dropdown } from '@/components/ui/shared/Dropdown';
+import { FORM_SELECT_CLASS } from '@/components/ui/shared/formSelectStyles';
import { SearchBar } from '@/components/ui/shared/SearchBar';
import { formatCaseSentSummary } from '@/components/treatment/caseSendLabel';
import { CaseSentLabel } from '@/components/ui/treatment/CaseSentLabel';
-import { TREATMENT_TYPE_KEYS, treatmentTypeLabelKey } from '@/components/ui/treatment/treatmentTypeDisplay';
+import { treatmentTypeLabelFromCatalog } from '@/components/ui/treatment/treatmentTypeDisplay';
+import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog';
+import type { ProsthesisCatalogEntry, TreatmentCatalogEntry } from '@/types/treatment-catalog';
import type { LabCaseDraft, LinkedOrganizationOption, TreatmentDetailDraft } from '@/types/treatment';
interface LabCasesDispatchPanelProps {
details: TreatmentDetailDraft[];
labCases: LabCaseDraft[];
labDependentCodes: Set;
+ treatmentCatalog: TreatmentCatalogEntry[];
activeLabCaseId: string | null;
onActiveLabCaseChange: (id: string) => void;
onLabCasesChange: (labCases: LabCaseDraft[]) => void;
@@ -52,7 +56,6 @@ function detailInOtherDraftShipment(
);
}
-/** Lab-dependent details not yet sent to any lab. */
function unsentLabDetails(
details: TreatmentDetailDraft[],
labCases: LabCaseDraft[],
@@ -62,7 +65,6 @@ function unsentLabDetails(
return details.filter((d) => labDependentCodes.has(d.treatmentType) && !sent.has(d.clientId));
}
-/** Unsent lab details not already assigned to another draft shipment. */
function detailsAvailableForNewShipment(
details: TreatmentDetailDraft[],
labCases: LabCaseDraft[],
@@ -73,7 +75,6 @@ function detailsAvailableForNewShipment(
);
}
-/** Details the user can pick for the active draft shipment. */
function selectableDetailsForDraftShipment(
details: TreatmentDetailDraft[],
labCases: LabCaseDraft[],
@@ -89,10 +90,40 @@ function selectableDetailsForDraftShipment(
});
}
+function prosthesisTeethRows(
+ labCase: LabCaseDraft,
+ details: TreatmentDetailDraft[],
+): Array<{ detailClientId: string; tooth: string; detailNumber: number }> {
+ const rows: Array<{ detailClientId: string; tooth: string; detailNumber: number }> = [];
+ for (const clientId of labCase.detailClientIds) {
+ const detail = details.find((d) => d.clientId === clientId);
+ if (!detail || detail.treatmentType !== 'prosthesis') continue;
+ const detailNumber = details.findIndex((d) => d.clientId === clientId) + 1;
+ for (const tooth of detail.teeth) {
+ rows.push({ detailClientId: clientId, tooth, detailNumber });
+ }
+ }
+ return rows;
+}
+
+function isProsthesisMapComplete(labCase: LabCaseDraft, details: TreatmentDetailDraft[]): boolean {
+ const rows = prosthesisTeethRows(labCase, details);
+ if (rows.length === 0) return true;
+ return rows.every((row) =>
+ labCase.toothProsthesis.some(
+ (tp) =>
+ tp.detailClientId === row.detailClientId &&
+ tp.tooth === row.tooth &&
+ Boolean(tp.prosthesisTypeCode),
+ ),
+ );
+}
+
export function LabCasesDispatchPanel({
details,
labCases,
labDependentCodes,
+ treatmentCatalog,
activeLabCaseId,
onActiveLabCaseChange,
onLabCasesChange,
@@ -108,6 +139,9 @@ export function LabCasesDispatchPanel({
onSendLabCase,
}: LabCasesDispatchPanelProps) {
const t = useTranslations('treatment');
+ const [prosthesisOptions, setProsthesisOptions] = useState([]);
+ const [applyAllProsthesis, setApplyAllProsthesis] = useState('');
+
const activeLinkedOrganizations = orgs.filter((o) => o.active);
const filteredOrganizations = (() => {
const q = organizationSearch.trim().toLowerCase();
@@ -136,17 +170,39 @@ export function LabCasesDispatchPanel({
? orgs.find((o) => o.id === activeLabCase.destinationOrganizationId)?.name
: null;
+ const prosthesisRows = activeLabCase ? prosthesisTeethRows(activeLabCase, details) : [];
+ const prosthesisComplete = activeLabCase
+ ? isProsthesisMapComplete(activeLabCase, details)
+ : true;
+
+ useEffect(() => {
+ if (!activeLabCase?.destinationOrganizationId) {
+ setProsthesisOptions([]);
+ return;
+ }
+
+ let cancelled = false;
+ void prosthesisCatalogApi
+ .list(activeLabCase.destinationOrganizationId)
+ .then((res) => {
+ if (!cancelled) setProsthesisOptions(res.data);
+ })
+ .catch(() => {
+ if (!cancelled) setProsthesisOptions([]);
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [activeLabCase?.destinationOrganizationId]);
+
function detailNumber(d: TreatmentDetailDraft) {
const idx = details.findIndex((row) => row.clientId === d.clientId);
return idx >= 0 ? idx + 1 : 0;
}
function detailSummary(d: TreatmentDetailDraft) {
- const typeKey = treatmentTypeLabelKey(d.treatmentType);
- const typeLabel =
- d.treatmentType in TREATMENT_TYPE_KEYS
- ? t(typeKey as 'typeEndo')
- : d.treatmentType;
+ const typeLabel = treatmentTypeLabelFromCatalog(d.treatmentType, treatmentCatalog);
const teeth = d.teeth.length ? d.teeth.join(', ') : t('teethNone');
return `${t('detailLabel', { n: detailNumber(d) })} · ${typeLabel} · ${teeth}`;
}
@@ -158,6 +214,31 @@ export function LabCasesDispatchPanel({
);
}
+ function setToothProsthesis(
+ detailClientId: string,
+ tooth: string,
+ prosthesisTypeCode: string,
+ ) {
+ if (!activeLabCase) return;
+ const rest = activeLabCase.toothProsthesis.filter(
+ (tp) => !(tp.detailClientId === detailClientId && tp.tooth === tooth),
+ );
+ const next = prosthesisTypeCode
+ ? [...rest, { detailClientId, tooth, prosthesisTypeCode }]
+ : rest;
+ updateActiveLabCase({ toothProsthesis: next });
+ }
+
+ function applyProsthesisToAll(code: string) {
+ if (!activeLabCase || !code) return;
+ const next = prosthesisRows.map((row) => ({
+ detailClientId: row.detailClientId,
+ tooth: row.tooth,
+ prosthesisTypeCode: code,
+ }));
+ updateActiveLabCase({ toothProsthesis: next });
+ }
+
function toggleDetailInActiveLabCase(detailClientId: string, checked: boolean) {
if (!activeLabCase || sent) return;
@@ -169,7 +250,12 @@ export function LabCasesDispatchPanel({
const set = new Set(lc.detailClientIds);
if (checked) set.add(detailClientId);
else set.delete(detailClientId);
- return { ...lc, detailClientIds: [...set] };
+
+ const keptProsthesis = lc.toothProsthesis.filter((tp) =>
+ [...set].includes(tp.detailClientId),
+ );
+
+ return { ...lc, detailClientIds: [...set], toothProsthesis: keptProsthesis };
}
if (checked) {
@@ -375,11 +461,14 @@ export function LabCasesDispatchPanel({
)}
+ onChange={(e) => {
+ const nextOrgId = e.target.value || null;
updateActiveLabCase({
- destinationOrganizationId: e.target.value || null,
- })
- }
+ destinationOrganizationId: nextOrgId,
+ toothProsthesis: [],
+ });
+ setApplyAllProsthesis('');
+ }}
disabled={disabled || filteredOrganizations.length === 0}
>
{t('selectLabPlaceholder')}
@@ -394,6 +483,84 @@ export function LabCasesDispatchPanel({
)}
+ {prosthesisRows.length > 0 && activeLabCase.destinationOrganizationId ? (
+
+
+ {t('prosthesisTypesTitle')}
+
+
+ {t('prosthesisApplyAll')}
+ {
+ const code = e.target.value;
+ setApplyAllProsthesis(code);
+ if (code) applyProsthesisToAll(code);
+ }}
+ className={`${FORM_SELECT_CLASS} w-full mt-1`}
+ >
+ {t('prosthesisSelectPlaceholder')}
+ {prosthesisOptions.map((opt) => (
+
+ {opt.label}
+
+ ))}
+
+
+
+
+
+
+ {t('prosthesisColTooth')}
+ {t('prosthesisColDetail')}
+ {t('prosthesisColType')}
+
+
+
+ {prosthesisRows.map((row) => {
+ const current =
+ activeLabCase.toothProsthesis.find(
+ (tp) =>
+ tp.detailClientId === row.detailClientId &&
+ tp.tooth === row.tooth,
+ )?.prosthesisTypeCode ?? '';
+ return (
+
+ {row.tooth}
+
+ {t('detailLabel', { n: row.detailNumber })}
+
+
+
+ setToothProsthesis(
+ row.detailClientId,
+ row.tooth,
+ e.target.value,
+ )
+ }
+ className={`${FORM_SELECT_CLASS} w-full min-w-[160px]`}
+ >
+ {t('prosthesisSelectPlaceholder')}
+ {prosthesisOptions.map((opt) => (
+
+ {opt.label}
+
+ ))}
+
+
+
+ );
+ })}
+
+
+
+
+ ) : null}
+
onSendLabCase(activeLabCase)}
diff --git a/frontend/src/components/ui/treatment/PastTreatmentsPanel.tsx b/frontend/src/components/ui/treatment/PastTreatmentsPanel.tsx
index 178f55f..a97a225 100644
--- a/frontend/src/components/ui/treatment/PastTreatmentsPanel.tsx
+++ b/frontend/src/components/ui/treatment/PastTreatmentsPanel.tsx
@@ -3,9 +3,11 @@
import { useTranslations } from 'next-intl';
import { TreatmentHistoryDetailLine } from '@/components/ui/treatment/TreatmentHistoryDetailLine';
import type { PastTreatment } from '@/types/treatment';
+import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
interface PastTreatmentsPanelProps {
items: PastTreatment[];
+ treatmentCatalog: TreatmentCatalogEntry[];
loading?: boolean;
selectedPreviewId?: string | null;
onSelectTreatment?: (treatment: PastTreatment) => void;
@@ -13,6 +15,7 @@ interface PastTreatmentsPanelProps {
export function PastTreatmentsPanel({
items,
+ treatmentCatalog,
loading,
selectedPreviewId,
onSelectTreatment,
@@ -78,6 +81,7 @@ export function PastTreatmentsPanel({
))}
diff --git a/frontend/src/components/ui/treatment/TreatmentDetailSummaryRow.tsx b/frontend/src/components/ui/treatment/TreatmentDetailSummaryRow.tsx
index ea16c8a..1b617ac 100644
--- a/frontend/src/components/ui/treatment/TreatmentDetailSummaryRow.tsx
+++ b/frontend/src/components/ui/treatment/TreatmentDetailSummaryRow.tsx
@@ -3,12 +3,15 @@
import { useTranslations } from 'next-intl';
import { DetailLabSendBadge } from '@/components/ui/treatment/DetailLabSendBadge';
import { TreatmentTypeBadge } from '@/components/ui/treatment/TreatmentTypeBadge';
+import { treatmentTypeLabelFromCatalog } from '@/components/ui/treatment/treatmentTypeDisplay';
+import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
import type { LinkedOrganizationOption, PastTreatmentDetail } from '@/types/treatment';
interface TreatmentDetailSummaryRowProps {
detail: PastTreatmentDetail;
detailNumber: number;
labDependentCodes: Set;
+ treatmentCatalog: TreatmentCatalogEntry[];
orgs?: LinkedOrganizationOption[];
compact?: boolean;
}
@@ -17,6 +20,7 @@ export function TreatmentDetailSummaryRow({
detail,
detailNumber,
labDependentCodes,
+ treatmentCatalog,
orgs,
compact = false,
}: TreatmentDetailSummaryRowProps) {
@@ -35,7 +39,10 @@ export function TreatmentDetailSummaryRow({
{t('detailLabel', { n: detailNumber })}
-
+
diff --git a/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx b/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx
index ab4b388..aa867af 100644
--- a/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx
+++ b/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx
@@ -10,7 +10,8 @@ import {
labSentBannerClass,
} from '@/components/ui/treatment/treatmentStatusStyles';
import type { TreatmentDetailDraft } from '@/types/treatment';
-import { TREATMENT_TYPE_COLORS, treatmentTypeLabelKey } from '@/components/ui/treatment/treatmentTypeDisplay';
+import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
+import { treatmentTypeColor } from '@/components/ui/treatment/treatmentTypeDisplay';
interface TreatmentDetailsEditorProps {
details: TreatmentDetailDraft[];
@@ -19,6 +20,7 @@ interface TreatmentDetailsEditorProps {
onDetailsChange: (details: TreatmentDetailDraft[]) => void;
isDetailLocked: (detail: TreatmentDetailDraft) => boolean;
labDependentCodes: Set;
+ treatmentCatalog: TreatmentCatalogEntry[];
disabled: boolean;
canEdit: boolean;
saveStatus: 'idle' | 'dirty' | 'saving' | 'saved' | 'error';
@@ -34,6 +36,7 @@ export function TreatmentDetailsEditor({
onDetailsChange,
isDetailLocked,
labDependentCodes,
+ treatmentCatalog,
disabled,
canEdit,
saveStatus,
@@ -49,7 +52,10 @@ export function TreatmentDetailsEditor({
const locked = isDetailLocked(activeDetail);
const readOnly = disabled || locked;
- const treatmentTypeTextColor = TREATMENT_TYPE_COLORS[activeDetail.treatmentType];
+ const treatmentTypeTextColor = treatmentTypeColor(
+ activeDetail.treatmentType,
+ treatmentCatalog.findIndex((e) => e.code === activeDetail.treatmentType),
+ );
const isLabDependent = labDependentCodes.has(activeDetail.treatmentType);
const showPendingLabHint = isLabDependent && !locked && !readOnly;
@@ -125,14 +131,20 @@ export function TreatmentDetailsEditor({
);
}}
disabled={readOnly}
- className="capitalize"
style={{ color: treatmentTypeTextColor }}
>
- {t('typeConsultation')}
- {t('typeFilling')}
- {t('typeEndo')}
- {t('typeVisit')}
- {t('typeHygiene')}
+ {treatmentCatalog.map((entry, index) => (
+
+ {entry.label}
+
+ ))}
diff --git a/frontend/src/components/ui/treatment/TreatmentHistoryDetailLine.tsx b/frontend/src/components/ui/treatment/TreatmentHistoryDetailLine.tsx
index d549190..606190b 100644
--- a/frontend/src/components/ui/treatment/TreatmentHistoryDetailLine.tsx
+++ b/frontend/src/components/ui/treatment/TreatmentHistoryDetailLine.tsx
@@ -2,16 +2,20 @@
import { useTranslations } from 'next-intl';
import { TreatmentTypeBadge } from '@/components/ui/treatment/TreatmentTypeBadge';
+import { treatmentTypeLabelFromCatalog } from '@/components/ui/treatment/treatmentTypeDisplay';
+import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
import type { PastTreatmentDetail } from '@/types/treatment';
interface TreatmentHistoryDetailLineProps {
detail: PastTreatmentDetail;
detailNumber: number;
+ treatmentCatalog: TreatmentCatalogEntry[];
}
export function TreatmentHistoryDetailLine({
detail,
detailNumber,
+ treatmentCatalog,
}: TreatmentHistoryDetailLineProps) {
const t = useTranslations('treatment');
const teeth = detail.teeth.length ? [...detail.teeth].sort().join(', ') : t('teethNone');
@@ -20,7 +24,10 @@ export function TreatmentHistoryDetailLine({
return (
{detailNumber}.
-
+
{teeth}
{attachmentCount > 0 && (
diff --git a/frontend/src/components/ui/treatment/TreatmentPreviewCard.tsx b/frontend/src/components/ui/treatment/TreatmentPreviewCard.tsx
index 4349be3..67385c0 100644
--- a/frontend/src/components/ui/treatment/TreatmentPreviewCard.tsx
+++ b/frontend/src/components/ui/treatment/TreatmentPreviewCard.tsx
@@ -4,10 +4,12 @@ import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/shared/Button';
import { TreatmentDetailSummaryRow } from '@/components/ui/treatment/TreatmentDetailSummaryRow';
import type { LinkedOrganizationOption, PastTreatment } from '@/types/treatment';
+import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
interface TreatmentPreviewCardProps {
treatment: PastTreatment | null;
labDependentCodes: Set;
+ treatmentCatalog: TreatmentCatalogEntry[];
orgs?: LinkedOrganizationOption[];
openDisabled?: boolean;
onOpen: () => void;
@@ -16,6 +18,7 @@ interface TreatmentPreviewCardProps {
export function TreatmentPreviewCard({
treatment,
labDependentCodes,
+ treatmentCatalog,
orgs,
openDisabled = false,
onOpen,
@@ -53,6 +56,7 @@ export function TreatmentPreviewCard({
detail={detail}
detailNumber={idx + 1}
labDependentCodes={labDependentCodes}
+ treatmentCatalog={treatmentCatalog}
orgs={orgs}
compact
/>
diff --git a/frontend/src/components/ui/treatment/TreatmentTypeBadge.tsx b/frontend/src/components/ui/treatment/TreatmentTypeBadge.tsx
index 9c01bb6..8f8cc55 100644
--- a/frontend/src/components/ui/treatment/TreatmentTypeBadge.tsx
+++ b/frontend/src/components/ui/treatment/TreatmentTypeBadge.tsx
@@ -1,25 +1,22 @@
'use client';
-import { useTranslations } from 'next-intl';
import { purposeStyle } from '@/components/ui/appointments/appointmentPurposeStyles';
-import { TREATMENT_TYPE_KEYS, treatmentTypeLabelKey } from '@/components/ui/treatment/treatmentTypeDisplay';
+import { formatCodeAsLabel } from '@/components/ui/treatment/treatmentTypeDisplay';
interface TreatmentTypeBadgeProps {
type: string;
+ label?: string;
className?: string;
}
-export function TreatmentTypeBadge({ type, className = '' }: TreatmentTypeBadgeProps) {
- const t = useTranslations('treatment');
- const typeKey = treatmentTypeLabelKey(type);
- const label =
- type in TREATMENT_TYPE_KEYS ? t(typeKey as 'typeEndo') : type;
+export function TreatmentTypeBadge({ type, label, className = '' }: TreatmentTypeBadgeProps) {
+ const display = label ?? formatCodeAsLabel(type);
return (
- {label}
+ {display}
);
}
diff --git a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx
index 8d0d40e..b38cb1f 100644
--- a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx
+++ b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx
@@ -9,7 +9,7 @@ import { PastTreatmentsPanel } from '@/components/ui/treatment/PastTreatmentsPan
import { TreatmentDetailsEditor } from '@/components/ui/treatment/TreatmentDetailsEditor';
import { TreatmentPreviewCard } from '@/components/ui/treatment/TreatmentPreviewCard';
import { ToastStack } from '@/components/ui/shared/Toast';
-import { treatmentTypeLabelKey } from '@/components/ui/treatment/treatmentTypeDisplay';
+import { treatmentTypeLabelFromCatalog } from '@/components/ui/treatment/treatmentTypeDisplay';
import {
addCalendarDays,
compareLocalDayStart,
@@ -25,6 +25,7 @@ import { formatApiErrorMessage } from '@/components/shared/formatApiError';
import { useToast } from '@/lib/hooks/useToast';
import type { Organization } from '@/types/organization';
import type { AppointmentRecord } from '@/types/appointment';
+import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
import type {
FdiToothId,
LabCaseDraft,
@@ -93,7 +94,7 @@ function newDetail(): TreatmentDetailDraft {
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: `detail-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
- treatmentType: 'consultation',
+ treatmentType: 'restoration',
teeth: [],
comment: '',
attachmentMetas: [],
@@ -111,6 +112,7 @@ function newLabCaseDraft(): LabCaseDraft {
destinationOrganizationId: null,
labComment: '',
detailClientIds: [],
+ toothProsthesis: [],
sentAt: null,
sends: [],
};
@@ -145,12 +147,20 @@ function mapDetailFromApi(d: PastTreatmentCase): TreatmentDetailDraft {
}
function mapLabCaseDraftFromApi(lc: PastLabCase): LabCaseDraft {
+ const detailClientById = new Map(lc.details.map((d) => [d.id, d.clientId]));
+
return {
clientId: lc.clientId,
id: lc.id,
destinationOrganizationId: lc.destinationOrganizationId,
labComment: lc.labComment ?? '',
detailClientIds: lc.details.map((d) => d.clientId),
+ toothProsthesis: (lc.toothProsthesis ?? []).map((tp) => ({
+ detailClientId:
+ detailClientById.get(tp.treatmentDetailId) ?? tp.treatmentDetailId,
+ tooth: tp.tooth,
+ prosthesisTypeCode: tp.prosthesisTypeCode,
+ })),
sentAt: lc.sentAt ?? null,
sends: lc.sends ?? [],
};
@@ -231,6 +241,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
const [orgs, setOrgs] = useState([]);
const [labDependentCodes, setLabDependentCodes] = useState>(new Set());
+ const [treatmentCatalog, setTreatmentCatalog] = useState([]);
const [details, setDetails] = useState(() => [newDetail()]);
const [labCaseDrafts, setLabCaseDrafts] = useState([]);
@@ -414,6 +425,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
]);
if (cancelled) return;
setOrgs(orgsResponse.data);
+ setTreatmentCatalog(catalogResponse.data);
setLabDependentCodes(
new Set(catalogResponse.data.filter((entry) => entry.labDependent).map((entry) => entry.code)),
);
@@ -765,6 +777,17 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
treatmentDetailIds: lc.detailClientIds
.map((clientId) => detailIdByClientId.get(clientId))
.filter((id): id is string => Boolean(id)),
+ toothProsthesis: lc.toothProsthesis
+ .map((tp) => {
+ const detailId = detailIdByClientId.get(tp.detailClientId);
+ if (!detailId) return null;
+ return {
+ treatmentDetailId: detailId,
+ tooth: tp.tooth,
+ prosthesisTypeCode: tp.prosthesisTypeCode,
+ };
+ })
+ .filter((row): row is { treatmentDetailId: string; tooth: string; prosthesisTypeCode: string } => row !== null),
}));
if (payload.length === 0) {
@@ -881,6 +904,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
appointments={appointments}
selectedAppointmentId={selectedAppointmentId}
onSelectAppointment={onPickAppointment}
+ treatmentCatalog={treatmentCatalog}
loading={apptsLoading}
/>
@@ -906,10 +930,8 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
{t('purposeLabel')}{' '}
-
- {t(
- treatmentTypeLabelKey(selectedAppointment.purpose) as 'typeConsultation',
- )}
+
+ {treatmentTypeLabelFromCatalog(selectedAppointment.purpose, treatmentCatalog)}
@@ -922,6 +944,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
= {
- consultation: '#ddd6fe',
- filling: '#fed7aa',
+const TREATMENT_TYPE_COLORS: Record = {
+ restoration: '#fed7aa',
+ specialized_restoration: '#fdba74',
+ radiography: '#e2e8f0',
endo: '#fecaca',
- visit: '#bae6fd',
- hygiene: '#d9f99d',
+ surgery: '#fca5a5',
+ prosthesis: '#c4b5fd',
+ implant: '#a5b4fc',
+ orthodontics: '#93c5fd',
+ perio: '#86efac',
+ pediatrics: '#fde68a',
+ extraction: '#f87171',
+ clinic_visit: '#bae6fd',
};
-export function treatmentTypeLabelKey(code: string): string {
- return TREATMENT_TYPE_KEYS[code as keyof typeof TREATMENT_TYPE_KEYS] ?? code;
+const FALLBACK_COLORS = ['#ddd6fe', '#fed7aa', '#fecaca', '#bae6fd', '#d9f99d'];
+
+export function treatmentTypeColor(code: string, index = 0): string {
+ return TREATMENT_TYPE_COLORS[code] ?? FALLBACK_COLORS[index % FALLBACK_COLORS.length];
+}
+
+export function treatmentTypeLabelFromCatalog(
+ code: string,
+ catalog: TreatmentCatalogEntry[],
+): string {
+ return catalog.find((e) => e.code === code)?.label ?? formatCodeAsLabel(code);
+}
+
+export function formatCodeAsLabel(code: string): string {
+ return code
+ .split('_')
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
+ .join(' ');
+}
+
+/** @deprecated Use treatmentTypeLabelFromCatalog with API catalog */
+export function treatmentTypeLabelKey(code: string): string {
+ return `type_${code}`;
}
diff --git a/frontend/src/lib/api/prosthesis-catalog.ts b/frontend/src/lib/api/prosthesis-catalog.ts
new file mode 100644
index 0000000..1e89e0a
--- /dev/null
+++ b/frontend/src/lib/api/prosthesis-catalog.ts
@@ -0,0 +1,13 @@
+import { apiClient } from './client';
+import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
+
+export const prosthesisCatalogApi = {
+ list: async (
+ labOrganizationId?: string,
+ ): Promise<{ success: boolean; data: ProsthesisCatalogEntry[] }> => {
+ const response = await apiClient.get('/prosthesis-catalog', {
+ params: labOrganizationId ? { labOrganizationId } : undefined,
+ });
+ return response.data;
+ },
+};
diff --git a/frontend/src/types/cases.ts b/frontend/src/types/cases.ts
index 316d664..ba18314 100644
--- a/frontend/src/types/cases.ts
+++ b/frontend/src/types/cases.ts
@@ -18,6 +18,9 @@ export interface LabCaseTask {
id: string;
tooth: string;
treatmentType: string;
+ prosthesisTypeCode: string;
+ prosthesisTypeLabel: string;
+ workflowStepCode?: string;
stepOrder: number;
stepLabel: string;
status: LabTaskStatus;
@@ -31,6 +34,8 @@ export interface LabCaseTask {
export interface LabCaseTasksByTooth {
tooth: string;
treatmentType: string;
+ prosthesisTypeCode: string;
+ prosthesisTypeLabel: string;
tasks: LabCaseTask[];
}
@@ -100,6 +105,8 @@ export interface LabTaskListItem {
labCaseId: string;
tooth: string;
treatmentType: string;
+ prosthesisTypeCode: string;
+ prosthesisTypeLabel: string;
stepOrder: number;
stepLabel: string;
status: LabTaskStatus;
diff --git a/frontend/src/types/treatment-catalog.ts b/frontend/src/types/treatment-catalog.ts
index 5ecb3d8..51b071a 100644
--- a/frontend/src/types/treatment-catalog.ts
+++ b/frontend/src/types/treatment-catalog.ts
@@ -3,4 +3,11 @@ export interface TreatmentCatalogEntry {
code: string;
labDependent: boolean;
sortOrder: number;
+ label: string;
+}
+
+export interface ProsthesisCatalogEntry {
+ code: string;
+ sortOrder: number;
+ label: string;
}
diff --git a/frontend/src/types/treatment.ts b/frontend/src/types/treatment.ts
index e1ab7f1..6c5d5a4 100644
--- a/frontend/src/types/treatment.ts
+++ b/frontend/src/types/treatment.ts
@@ -51,15 +51,14 @@ export interface TreatmentAttachmentMeta {
sizeBytes: number;
}
-export const TREATMENT_TYPES = [
- 'consultation',
- 'filling',
- 'endo',
- 'visit',
- 'hygiene',
-] as const;
+export type TreatmentType = string;
-export type TreatmentType = (typeof TREATMENT_TYPES)[number];
+export interface LabCaseToothProsthesisDraft {
+ /** Treatment detail client id in the UI; mapped to UUID when saving. */
+ detailClientId: string;
+ tooth: string;
+ prosthesisTypeCode: string;
+}
export interface LabCaseSendInfo {
organizationId: string;
@@ -99,6 +98,11 @@ export interface PastLabCase {
treatmentType: string;
teeth: FdiToothId[];
}>;
+ toothProsthesis?: Array<{
+ treatmentDetailId: string;
+ tooth: string;
+ prosthesisTypeCode: string;
+ }>;
sends?: LabCaseSendInfo[];
}
@@ -141,6 +145,7 @@ export interface LabCaseDraft {
destinationOrganizationId: string | null;
labComment: string;
detailClientIds: string[];
+ toothProsthesis: LabCaseToothProsthesisDraft[];
sentAt?: string | null;
sends?: LabCaseSendInfo[];
}
@@ -163,6 +168,11 @@ export interface SaveLabCasePayload {
destinationOrganizationId?: string;
labComment?: string;
treatmentDetailIds: string[];
+ toothProsthesis?: Array<{
+ treatmentDetailId: string;
+ tooth: string;
+ prosthesisTypeCode: string;
+ }>;
}
export interface SaveTreatmentPayload {
@@ -185,4 +195,5 @@ export interface LabCaseResponse {
teeth: string[];
}>;
sends: LabCaseSendInfo[];
+ toothProsthesis?: LabCaseToothProsthesisDraft[];
}
--
2.53.0.windows.1
From ed7e7b1d8f45b84482a44427ade78a8bab1faff6 Mon Sep 17 00:00:00 2001
From: Admin
Date: Tue, 7 Jul 2026 13:13:04 +0330
Subject: [PATCH 13/17] improvement: all clinic side ui components related to
treatment types updated based on the new real world data.
---
backend/prisma/catalog-seed-data.ts | 31 ++++++--
.../migration.sql | 3 +
backend/prisma/reset-treatment-data.ts | 46 +++++++++---
backend/prisma/schema.prisma | 12 ++--
backend/prisma/seed.ts | 6 ++
.../treatment-catalog.controller.ts | 29 ++++++--
.../treatment-catalog.service.ts | 30 ++++++--
.../(dashboard)/appointments/page.tsx | 14 +++-
.../appointments/AppointmentBookingModal.tsx | 42 +++++------
.../AppointmentOverlapPopover.tsx | 53 +++++++-------
.../appointments/AppointmentScheduleGrid.tsx | 9 ++-
.../AppointmentScheduleLegend.tsx | 28 +++++---
.../appointments/appointmentPurposeStyles.ts | 71 +++++++++----------
.../ui/treatment/AppointmentsStrip.tsx | 10 +--
.../ui/treatment/TreatmentTypeBadge.tsx | 9 ++-
.../ui/treatment/TreatmentWorkspace.tsx | 6 +-
.../ui/treatment/treatmentTypeDisplay.ts | 43 ++++++++++-
frontend/src/lib/api/treatment-catalog.ts | 10 ++-
frontend/src/types/appointment.ts | 15 ++--
frontend/src/types/treatment-catalog.ts | 2 +
20 files changed, 321 insertions(+), 148 deletions(-)
create mode 100644 backend/prisma/migrations/20260707120000_treatment_type_context/migration.sql
diff --git a/backend/prisma/catalog-seed-data.ts b/backend/prisma/catalog-seed-data.ts
index bfb7a7e..bdf5508 100644
--- a/backend/prisma/catalog-seed-data.ts
+++ b/backend/prisma/catalog-seed-data.ts
@@ -7,7 +7,17 @@ export type CatalogTranslationSeed = {
label: string;
};
-export const TREATMENT_TYPES = [
+export type TreatmentTypeSeed = {
+ code: string;
+ labDependent: boolean;
+ sortOrder: number;
+ /** Selectable when booking an appointment. Defaults to true. */
+ availableInAppointments?: boolean;
+ /** Selectable as a treatment plan detail. Defaults to true. */
+ availableInTreatment?: boolean;
+};
+
+export const TREATMENT_TYPES: readonly TreatmentTypeSeed[] = [
{ code: 'restoration', labDependent: false, sortOrder: 1 },
{ code: 'specialized_restoration', labDependent: false, sortOrder: 2 },
{ code: 'radiography', labDependent: false, sortOrder: 3 },
@@ -19,11 +29,23 @@ export const TREATMENT_TYPES = [
{ code: 'perio', labDependent: false, sortOrder: 9 },
{ code: 'pediatrics', labDependent: false, sortOrder: 10 },
{ code: 'extraction', labDependent: false, sortOrder: 11 },
- { code: 'clinic_visit', labDependent: false, sortOrder: 12 },
+ // Appointment-only: not real treatment plan details.
+ {
+ code: 'clinic_visit',
+ labDependent: false,
+ sortOrder: 12,
+ availableInTreatment: false,
+ },
+ {
+ code: 'continue_treatment',
+ labDependent: false,
+ sortOrder: 13,
+ availableInTreatment: false,
+ },
] as const;
/** Legacy codes kept for historical rows; hidden from catalog. */
-export const LEGACY_TREATMENT_TYPES = [
+export const LEGACY_TREATMENT_TYPES: readonly TreatmentTypeSeed[] = [
{ code: 'consultation', labDependent: false, sortOrder: 99 },
{ code: 'filling', labDependent: false, sortOrder: 100 },
{ code: 'visit', labDependent: false, sortOrder: 101 },
@@ -207,7 +229,8 @@ const TREATMENT_LABELS: Record> = {
perio: { en: 'Perio', fa: 'پریو', nl: 'Paro' },
pediatrics: { en: 'Pediatrics', fa: 'اطفال', nl: 'Kinderen' },
extraction: { en: 'Extraction', fa: 'کشیدن', nl: 'Extractie' },
- clinic_visit: { en: 'Clinic Visit', fa: 'درمانگاه', nl: 'Kliniekbezoek' },
+ clinic_visit: { en: 'Clinic Visit', fa: 'ویزیت درمانگاه', nl: 'Kliniekbezoek' },
+ continue_treatment: { en: 'Continue Treatment', fa: 'ادامه درمان', nl: 'Behandeling Voortzetten' },
consultation: { en: 'Consultation', fa: 'مشاوره', nl: 'Consult' },
filling: { en: 'Filling', fa: 'پر کردن', nl: 'Vulling' },
visit: { en: 'Visit', fa: 'ویزیت', nl: 'Bezoek' },
diff --git a/backend/prisma/migrations/20260707120000_treatment_type_context/migration.sql b/backend/prisma/migrations/20260707120000_treatment_type_context/migration.sql
new file mode 100644
index 0000000..5224edb
--- /dev/null
+++ b/backend/prisma/migrations/20260707120000_treatment_type_context/migration.sql
@@ -0,0 +1,3 @@
+-- Treatment type context flags: control which selection contexts each type appears in.
+ALTER TABLE "treatment_types" ADD COLUMN "availableInAppointments" BOOLEAN NOT NULL DEFAULT true;
+ALTER TABLE "treatment_types" ADD COLUMN "availableInTreatment" BOOLEAN NOT NULL DEFAULT true;
diff --git a/backend/prisma/reset-treatment-data.ts b/backend/prisma/reset-treatment-data.ts
index 1b93fdc..6bc6387 100644
--- a/backend/prisma/reset-treatment-data.ts
+++ b/backend/prisma/reset-treatment-data.ts
@@ -1,6 +1,9 @@
/**
* Dev-only: truncate treatment and lab case data (preserves catalog tables).
* Usage: npx ts-node prisma/reset-treatment-data.ts
+ *
+ * Safe to run before or after `prisma migrate deploy`: tables that do not yet
+ * exist are skipped instead of throwing.
*/
import { PrismaClient } from '@prisma/client';
import { config } from 'dotenv';
@@ -16,17 +19,44 @@ if (process.env.NODE_ENV === 'production') {
const prisma = new PrismaClient();
+// FK-safe order: children before parents.
+const TABLES_IN_ORDER = [
+ 'lab_case_tasks',
+ 'lab_case_sends',
+ 'lab_case_tooth_prosthesis',
+ 'lab_case_details',
+ 'lab_cases',
+ 'treatment_detail_attachments',
+ 'treatment_details',
+ 'treatments',
+];
+
+async function tableExists(table: string): Promise {
+ const rows = await prisma.$queryRawUnsafe>(
+ `SELECT to_regclass('public."${table}"')::text AS exists`,
+ );
+ return rows[0]?.exists != null;
+}
+
async function main() {
console.log('Truncating treatment and lab case data...');
- await prisma.$executeRawUnsafe('TRUNCATE TABLE "lab_case_tasks" CASCADE');
- await prisma.$executeRawUnsafe('TRUNCATE TABLE "lab_case_sends" CASCADE');
- await prisma.$executeRawUnsafe('TRUNCATE TABLE "lab_case_tooth_prosthesis" CASCADE');
- await prisma.$executeRawUnsafe('TRUNCATE TABLE "lab_case_details" CASCADE');
- await prisma.$executeRawUnsafe('TRUNCATE TABLE "lab_cases" CASCADE');
- await prisma.$executeRawUnsafe('TRUNCATE TABLE "treatment_detail_attachments" CASCADE');
- await prisma.$executeRawUnsafe('TRUNCATE TABLE "treatment_details" CASCADE');
- await prisma.$executeRawUnsafe('TRUNCATE TABLE "treatments" CASCADE');
+ const existing: string[] = [];
+ for (const table of TABLES_IN_ORDER) {
+ if (await tableExists(table)) {
+ existing.push(table);
+ } else {
+ console.log(` - skipping "${table}" (does not exist yet)`);
+ }
+ }
+
+ if (existing.length === 0) {
+ console.log('No target tables exist yet. Run `prisma migrate deploy` first.');
+ return;
+ }
+
+ const targets = existing.map((t) => `"${t}"`).join(', ');
+ await prisma.$executeRawUnsafe(`TRUNCATE TABLE ${targets} CASCADE`);
console.log('Done.');
}
diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma
index 828cc38..f076995 100644
--- a/backend/prisma/schema.prisma
+++ b/backend/prisma/schema.prisma
@@ -224,11 +224,13 @@ model LabCaseSend {
}
model TreatmentType {
- id String @id @default(uuid())
- code String @unique
- labDependent Boolean @default(false)
- sortOrder Int @default(0)
- isActive Boolean @default(true)
+ id String @id @default(uuid())
+ code String @unique
+ labDependent Boolean @default(false)
+ sortOrder Int @default(0)
+ isActive Boolean @default(true)
+ availableInAppointments Boolean @default(true)
+ availableInTreatment Boolean @default(true)
@@map("treatment_types")
}
diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts
index bbe550c..0a9a0dd 100644
--- a/backend/prisma/seed.ts
+++ b/backend/prisma/seed.ts
@@ -138,12 +138,16 @@ async function main() {
for (const type of [...TREATMENT_TYPES, ...LEGACY_TREATMENT_TYPES]) {
const isActive = TREATMENT_TYPES.some((t) => t.code === type.code);
+ const availableInAppointments = type.availableInAppointments ?? true;
+ const availableInTreatment = type.availableInTreatment ?? true;
await prisma.treatmentType.upsert({
where: { code: type.code },
update: {
labDependent: type.labDependent,
sortOrder: type.sortOrder,
isActive,
+ availableInAppointments,
+ availableInTreatment,
},
create: {
id: randomUUID(),
@@ -151,6 +155,8 @@ async function main() {
labDependent: type.labDependent,
sortOrder: type.sortOrder,
isActive,
+ availableInAppointments,
+ availableInTreatment,
},
});
}
diff --git a/backend/src/modules/treatment-catalog/treatment-catalog.controller.ts b/backend/src/modules/treatment-catalog/treatment-catalog.controller.ts
index 062a603..fef1323 100644
--- a/backend/src/modules/treatment-catalog/treatment-catalog.controller.ts
+++ b/backend/src/modules/treatment-catalog/treatment-catalog.controller.ts
@@ -1,7 +1,10 @@
-import { Controller, Get, Req, UseGuards } from '@nestjs/common';
-import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
+import { Controller, Get, Query, Req, UseGuards } from '@nestjs/common';
+import { ApiBearerAuth, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
-import { TreatmentCatalogService } from './treatment-catalog.service';
+import {
+ TreatmentCatalogContext,
+ TreatmentCatalogService,
+} from './treatment-catalog.service';
@ApiTags('treatment-catalog')
@ApiBearerAuth('JWT-auth')
@@ -12,8 +15,24 @@ export class TreatmentCatalogController {
@Get()
@ApiOperation({ summary: 'List active treatment types with localized labels' })
- async list(@Req() req: { user?: { language?: string | null } }) {
- const data = await this.treatmentCatalogService.list(req.user?.language);
+ @ApiQuery({
+ name: 'context',
+ required: false,
+ enum: ['appointment', 'treatment'],
+ description: 'Filter to types selectable in the given context',
+ })
+ async list(
+ @Req() req: { user?: { language?: string | null } },
+ @Query('context') context?: string,
+ ) {
+ const normalizedContext =
+ context === 'appointment' || context === 'treatment'
+ ? (context as TreatmentCatalogContext)
+ : undefined;
+ const data = await this.treatmentCatalogService.list(
+ req.user?.language,
+ normalizedContext,
+ );
return { success: true, data };
}
}
diff --git a/backend/src/modules/treatment-catalog/treatment-catalog.service.ts b/backend/src/modules/treatment-catalog/treatment-catalog.service.ts
index 7baf921..418bfb4 100644
--- a/backend/src/modules/treatment-catalog/treatment-catalog.service.ts
+++ b/backend/src/modules/treatment-catalog/treatment-catalog.service.ts
@@ -7,12 +7,16 @@ import {
normalizeCatalogLocale,
} from '../catalog/catalog-label.service';
+export type TreatmentCatalogContext = 'appointment' | 'treatment';
+
export type TreatmentTypeCatalogEntry = {
id: string;
code: string;
labDependent: boolean;
sortOrder: number;
label: string;
+ availableInAppointments: boolean;
+ availableInTreatment: boolean;
};
@Injectable()
@@ -33,7 +37,14 @@ export class TreatmentCatalogService implements OnModuleInit {
const rows = await this.prisma.treatmentType.findMany({
where: { isActive: true },
orderBy: [{ sortOrder: 'asc' }, { code: 'asc' }],
- select: { id: true, code: true, labDependent: true, sortOrder: true },
+ select: {
+ id: true,
+ code: true,
+ labDependent: true,
+ sortOrder: true,
+ availableInAppointments: true,
+ availableInTreatment: true,
+ },
});
this.byCode = new Map(
@@ -45,17 +56,26 @@ export class TreatmentCatalogService implements OnModuleInit {
labDependent: row.labDependent,
sortOrder: row.sortOrder,
label: row.code,
+ availableInAppointments: row.availableInAppointments,
+ availableInTreatment: row.availableInTreatment,
},
]),
);
this.loaded = true;
}
- async list(localeInput?: string | null): Promise {
+ async list(
+ localeInput?: string | null,
+ context?: TreatmentCatalogContext | null,
+ ): Promise {
await this.ensureLabels(localeInput);
- return [...this.byCode.values()].sort(
- (a, b) => a.sortOrder - b.sortOrder || a.code.localeCompare(b.code),
- );
+ return [...this.byCode.values()]
+ .filter((entry) => {
+ if (context === 'appointment') return entry.availableInAppointments;
+ if (context === 'treatment') return entry.availableInTreatment;
+ return true;
+ })
+ .sort((a, b) => a.sortOrder - b.sortOrder || a.code.localeCompare(b.code));
}
private async ensureLabels(localeInput?: string | null) {
diff --git a/frontend/src/app/[locale]/(dashboard)/appointments/page.tsx b/frontend/src/app/[locale]/(dashboard)/appointments/page.tsx
index 9d0a025..573c324 100644
--- a/frontend/src/app/[locale]/(dashboard)/appointments/page.tsx
+++ b/frontend/src/app/[locale]/(dashboard)/appointments/page.tsx
@@ -4,6 +4,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslations } from 'next-intl';
import { appointmentsApi } from '@/lib/api/appointments';
import { patientsApi } from '@/lib/api/patients';
+import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
+import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
import { useAuth } from '@/lib/hooks/useAuth';
import { canEditAppointments, hasPermission } from '@/components/shared/permissions';
import type { AppointmentColumnProvider, AppointmentRecord } from '@/types/appointment';
@@ -36,6 +38,7 @@ export default function AppointmentsPage() {
const [providers, setProviders] = useState([]);
const [appointments, setAppointments] = useState([]);
+ const [treatmentCatalog, setTreatmentCatalog] = useState([]);
const [loadingSchedule, setLoadingSchedule] = useState(false);
const toast = useToast();
@@ -114,6 +117,13 @@ export default function AppointmentsPage() {
void loadSchedule();
}, [loadSchedule]);
+ useEffect(() => {
+ void treatmentCatalogApi
+ .list('appointment')
+ .then((r) => setTreatmentCatalog(r.data))
+ .catch(() => {});
+ }, []);
+
useEffect(() => {
const t = setTimeout(() => {
void loadPatientsSearch(search);
@@ -301,7 +311,7 @@ export default function AppointmentsPage() {
-
+
handleSlotClick(startMinute, uid, name)}
onAppointmentClick={(apt) => handleAppointmentClick(apt)}
@@ -332,6 +343,7 @@ export default function AppointmentsPage() {
providerUserId={bookingProviderId}
providerName={bookingProviderName}
initialStartMinute={bookingStartMinute}
+ treatmentCatalog={treatmentCatalog}
editingAppointment={activeEditingAppointment}
onClose={() => {
setBookingOpen(false);
diff --git a/frontend/src/components/ui/appointments/AppointmentBookingModal.tsx b/frontend/src/components/ui/appointments/AppointmentBookingModal.tsx
index 0d38723..bd5a60f 100644
--- a/frontend/src/components/ui/appointments/AppointmentBookingModal.tsx
+++ b/frontend/src/components/ui/appointments/AppointmentBookingModal.tsx
@@ -6,8 +6,11 @@ import { Button } from '@/components/ui/shared/Button';
import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
import { Dropdown } from '@/components/ui/shared/Dropdown';
import type { AppointmentPurpose, AppointmentRecord } from '@/types/appointment';
-import { APPOINTMENT_PURPOSES } from '@/types/appointment';
-import { getPurposeLabel } from '@/components/ui/appointments/appointmentPurposeStyles';
+import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
+import {
+ DROPDOWN_OPTION_BG,
+ treatmentTypeColor,
+} from '@/components/ui/treatment/treatmentTypeDisplay';
import type { Patient } from '@/types/patient';
import {
combineLocalDateAndTime,
@@ -31,6 +34,7 @@ interface AppointmentBookingModalProps {
endAt: string;
purpose: AppointmentPurpose;
}) => Promise;
+ treatmentCatalog: TreatmentCatalogEntry[];
editingAppointment?: AppointmentRecord | null;
loading?: boolean;
canDelete?: boolean;
@@ -38,14 +42,6 @@ interface AppointmentBookingModalProps {
deleting?: boolean;
}
-const PURPOSE_OPTION_COLORS: Record = {
- consultation: '#ddd6fe',
- filling: '#fed7aa',
- endo: '#fecaca',
- visit: '#bae6fd',
- hygiene: '#d9f99d',
-};
-
export function AppointmentBookingModal({
open,
scheduleDate,
@@ -55,6 +51,7 @@ export function AppointmentBookingModal({
initialStartMinute,
onClose,
onSubmit,
+ treatmentCatalog,
editingAppointment = null,
loading = false,
canDelete = false,
@@ -65,11 +62,14 @@ export function AppointmentBookingModal({
const tCommon = useTranslations('common');
const tPatients = useTranslations('patients');
+ const defaultPurpose = treatmentCatalog[0]?.code ?? '';
+
const [startTime, setStartTime] = useState('09:00');
const [endTime, setEndTime] = useState('10:00');
- const [purpose, setPurpose] = useState('consultation');
+ const [purpose, setPurpose] = useState(defaultPurpose);
const [error, setError] = useState('');
- const purposeTextColor = PURPOSE_OPTION_COLORS[purpose];
+ const purposeIndex = treatmentCatalog.findIndex((e) => e.code === purpose);
+ const purposeTextColor = treatmentTypeColor(purpose, purposeIndex < 0 ? 0 : purposeIndex);
useEffect(() => {
if (!open) {
@@ -80,7 +80,7 @@ export function AppointmentBookingModal({
const end = new Date(editingAppointment.endAt);
setStartTime(formatTimeForInput(start));
setEndTime(formatTimeForInput(end));
- setPurpose((editingAppointment.purpose as AppointmentPurpose) ?? 'consultation');
+ setPurpose(editingAppointment.purpose || defaultPurpose);
} else {
const start = new Date(
scheduleDate.getFullYear(),
@@ -103,10 +103,10 @@ export function AppointmentBookingModal({
);
setStartTime(formatTimeForInput(start));
setEndTime(formatTimeForInput(end));
- setPurpose('consultation');
+ setPurpose(defaultPurpose);
}
setError('');
- }, [open, scheduleDate, initialStartMinute, editingAppointment]);
+ }, [open, scheduleDate, initialStartMinute, editingAppointment, defaultPurpose]);
if (!open || !providerUserId) {
return null;
@@ -224,16 +224,16 @@ export function AppointmentBookingModal({
setPurpose(e.target.value as AppointmentPurpose)}
+ onChange={(e) => setPurpose(e.target.value)}
style={{ color: purposeTextColor }}
>
- {APPOINTMENT_PURPOSES.map((purposeOption) => (
+ {treatmentCatalog.map((entry, index) => (
- {getPurposeLabel(purposeOption, t)}
+ {entry.label}
))}
diff --git a/frontend/src/components/ui/appointments/AppointmentOverlapPopover.tsx b/frontend/src/components/ui/appointments/AppointmentOverlapPopover.tsx
index 5d18b14..04b4efd 100644
--- a/frontend/src/components/ui/appointments/AppointmentOverlapPopover.tsx
+++ b/frontend/src/components/ui/appointments/AppointmentOverlapPopover.tsx
@@ -4,13 +4,15 @@ import { useEffect, useRef } from 'react';
import { useTranslations } from 'next-intl';
import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
import {
- getPurposeLabel,
- purposeStyle,
+ purposeBannerStyle,
+ purposeLabel,
} from '@/components/ui/appointments/appointmentPurposeStyles';
-import type { AppointmentPurpose, AppointmentRecord } from '@/types/appointment';
+import type { AppointmentRecord } from '@/types/appointment';
+import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
type AppointmentOverlapPopoverProps = {
appointments: AppointmentRecord[];
+ treatmentCatalog: TreatmentCatalogEntry[];
anchorRect: DOMRect;
onSelect: (appointment: AppointmentRecord) => void;
onClose: () => void;
@@ -25,6 +27,7 @@ function formatTimeRange(apt: AppointmentRecord): string {
export function AppointmentOverlapPopover({
appointments,
+ treatmentCatalog,
anchorRect,
onSelect,
onClose,
@@ -82,29 +85,27 @@ export function AppointmentOverlapPopover({
- {sorted.map((apt) => {
- const purpose = apt.purpose as AppointmentPurpose;
- return (
-
- {
- onSelect(apt);
- onClose();
- }}
- className={`w-full rounded-[var(--radius-sm)] border px-2.5 py-2 text-left transition-colors hover:brightness-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/35 ${purposeStyle(apt.purpose)}`}
- >
-
- {apt.patient.firstName} {apt.patient.lastName}
-
- {formatTimeRange(apt)}
-
- {getPurposeLabel(purpose, t) ?? apt.purpose}
-
-
-
- );
- })}
+ {sorted.map((apt) => (
+
+ {
+ onSelect(apt);
+ onClose();
+ }}
+ className="w-full rounded-[var(--radius-sm)] border px-2.5 py-2 text-left transition-colors hover:brightness-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/35"
+ style={purposeBannerStyle(apt.purpose, treatmentCatalog)}
+ >
+
+ {apt.patient.firstName} {apt.patient.lastName}
+
+ {formatTimeRange(apt)}
+
+ {purposeLabel(apt.purpose, treatmentCatalog)}
+
+
+
+ ))}
diff --git a/frontend/src/components/ui/appointments/AppointmentScheduleGrid.tsx b/frontend/src/components/ui/appointments/AppointmentScheduleGrid.tsx
index 64ca665..5cabee7 100644
--- a/frontend/src/components/ui/appointments/AppointmentScheduleGrid.tsx
+++ b/frontend/src/components/ui/appointments/AppointmentScheduleGrid.tsx
@@ -18,10 +18,11 @@ import {
findOverlapCluster,
lanePositionStyles,
} from '@/components/appointments/appointmentOverlapLayout';
-import { purposeStyle } from '@/components/ui/appointments/appointmentPurposeStyles';
+import { purposeBannerStyle } from '@/components/ui/appointments/appointmentPurposeStyles';
import { AppointmentOverlapPopover } from '@/components/ui/appointments/AppointmentOverlapPopover';
import { formatMobileForDisplay } from '@/lib/phone';
import { startOfLocalDay } from '@/components/appointments/appointmentTime';
+import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
const HOUR_PX = 80;
const SLOT_PX = (HOUR_PX * SCHEDULE_SLOT_MINUTES) / 60;
@@ -78,6 +79,7 @@ interface AppointmentScheduleGridProps {
day: Date;
providers: AppointmentColumnProvider[];
appointments: AppointmentRecord[];
+ treatmentCatalog: TreatmentCatalogEntry[];
canBook: boolean;
onSlotClick: (startMinute: number, providerUserId: string, providerName: string) => void;
onAppointmentClick?: (appointment: AppointmentRecord) => void;
@@ -88,6 +90,7 @@ export function AppointmentScheduleGrid({
day,
providers,
appointments,
+ treatmentCatalog,
canBook,
onSlotClick,
onAppointmentClick,
@@ -320,7 +323,7 @@ export function AppointmentScheduleGrid({
e.currentTarget,
)
}
- className={`absolute min-h-0 overflow-hidden rounded-[var(--radius-sm)] border pointer-events-auto z-10 flex text-left ${purposeStyle(apt.purpose)} focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/35 ${
+ className={`absolute min-h-0 overflow-hidden rounded-[var(--radius-sm)] border pointer-events-auto z-10 flex text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/35 ${
outsideHours ? 'opacity-70 ring-1 ring-amber-500/60' : ''
} ${
isUnderOneHour
@@ -332,6 +335,7 @@ export function AppointmentScheduleGrid({
height: pos.height,
left: lanePos.left,
width: lanePos.width,
+ ...purposeBannerStyle(apt.purpose, treatmentCatalog),
}}
title={bannerTitle}
>
@@ -366,6 +370,7 @@ export function AppointmentScheduleGrid({
{overlapPopover && (
{
const provider = providers.find((p) => p.userId === apt.providerUserId);
diff --git a/frontend/src/components/ui/appointments/AppointmentScheduleLegend.tsx b/frontend/src/components/ui/appointments/AppointmentScheduleLegend.tsx
index 8ab970f..041a2e6 100644
--- a/frontend/src/components/ui/appointments/AppointmentScheduleLegend.tsx
+++ b/frontend/src/components/ui/appointments/AppointmentScheduleLegend.tsx
@@ -1,25 +1,33 @@
'use client';
import { useTranslations } from 'next-intl';
-import {
- APPOINTMENT_PURPOSE_LEGEND_SWATCH,
- getPurposeLabel,
-} from '@/components/ui/appointments/appointmentPurposeStyles';
-import { APPOINTMENT_PURPOSES } from '@/types/appointment';
+import { purposeSwatchStyle } from '@/components/ui/appointments/appointmentPurposeStyles';
+import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
-export function AppointmentScheduleLegend() {
+interface AppointmentScheduleLegendProps {
+ treatmentCatalog: TreatmentCatalogEntry[];
+}
+
+export function AppointmentScheduleLegend({
+ treatmentCatalog,
+}: AppointmentScheduleLegendProps) {
const t = useTranslations('appointments');
+ if (treatmentCatalog.length === 0) {
+ return null;
+ }
+
return (
{t('legend')}
- {APPOINTMENT_PURPOSES.map((p) => (
-
+ {treatmentCatalog.map((entry) => (
+
- {getPurposeLabel(p, t)}
+ {entry.label}
))}
diff --git a/frontend/src/components/ui/appointments/appointmentPurposeStyles.ts b/frontend/src/components/ui/appointments/appointmentPurposeStyles.ts
index 813befa..71109e5 100644
--- a/frontend/src/components/ui/appointments/appointmentPurposeStyles.ts
+++ b/frontend/src/components/ui/appointments/appointmentPurposeStyles.ts
@@ -1,46 +1,39 @@
-import type { AppointmentPurpose } from '@/types/appointment';
+import type { CSSProperties } from 'react';
+import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
+import {
+ treatmentTypeBannerStyle,
+ treatmentTypeLabelFromCatalog,
+ treatmentTypeSwatchStyle,
+} from '@/components/ui/treatment/treatmentTypeDisplay';
-export const APPOINTMENT_PURPOSE_LABEL_KEYS = {
- consultation: 'purposeConsultation',
- filling: 'purposeFilling',
- endo: 'purposeEndo',
- visit: 'purposeVisit',
- hygiene: 'purposeHygiene',
-} as const satisfies Record
;
+/**
+ * Appointment purposes are treatment-type codes. Labels and colors now come from
+ * the shared treatment catalog + palette so the appointment and treatment
+ * features stay in sync. These helpers adapt the shared palette to the appointment
+ * components' call sites.
+ */
-export type AppointmentPurposeLabelKey =
- (typeof APPOINTMENT_PURPOSE_LABEL_KEYS)[AppointmentPurpose];
-
-export type AppointmentPurposeTranslate = (key: AppointmentPurposeLabelKey) => string;
-
-export function getPurposeLabel(
- purpose: AppointmentPurpose,
- t: AppointmentPurposeTranslate,
+export function purposeLabel(
+ purpose: string,
+ catalog: TreatmentCatalogEntry[],
): string {
- const key = APPOINTMENT_PURPOSE_LABEL_KEYS[purpose];
- return key ? t(key) : purpose;
+ return treatmentTypeLabelFromCatalog(purpose, catalog);
}
-/** Background + border for blocks / legend (matches reference palette). */
-export const APPOINTMENT_PURPOSE_STYLES: Record = {
- consultation:
- 'bg-purpose-consultation-bg border-purpose-consultation-border text-purpose-consultation-fg',
- filling: 'bg-purpose-filling-bg border-purpose-filling-border text-purpose-filling-fg',
- endo: 'bg-purpose-endo-bg border-purpose-endo-border text-purpose-endo-fg',
- visit: 'bg-purpose-visit-bg border-purpose-visit-border text-purpose-visit-fg',
- hygiene: 'bg-purpose-hygiene-bg border-purpose-hygiene-border text-purpose-hygiene-fg',
-};
-
-export function purposeStyle(purpose: string): string {
- const p = purpose as AppointmentPurpose;
- return APPOINTMENT_PURPOSE_STYLES[p] ?? 'bg-surface-elevated border-border text-text-secondary';
+/** Inline style for a colored appointment banner/block. */
+export function purposeBannerStyle(
+ purpose: string,
+ catalog: TreatmentCatalogEntry[],
+): CSSProperties {
+ const index = catalog.findIndex((e) => e.code === purpose);
+ return treatmentTypeBannerStyle(purpose, index);
}
-/** Small swatch for legend (background + border only). */
-export const APPOINTMENT_PURPOSE_LEGEND_SWATCH: Record = {
- consultation: 'bg-violet-500/85 border-violet-400/75',
- filling: 'bg-orange-500/85 border-orange-400/75',
- endo: 'bg-red-500/85 border-red-400/75',
- visit: 'bg-sky-500/85 border-sky-400/75',
- hygiene: 'bg-lime-500/80 border-lime-400/70',
-};
+/** Inline style for a small legend swatch. */
+export function purposeSwatchStyle(
+ purpose: string,
+ catalog: TreatmentCatalogEntry[],
+): CSSProperties {
+ const index = catalog.findIndex((e) => e.code === purpose);
+ return treatmentTypeSwatchStyle(purpose, index);
+}
diff --git a/frontend/src/components/ui/treatment/AppointmentsStrip.tsx b/frontend/src/components/ui/treatment/AppointmentsStrip.tsx
index 527b9ee..8326448 100644
--- a/frontend/src/components/ui/treatment/AppointmentsStrip.tsx
+++ b/frontend/src/components/ui/treatment/AppointmentsStrip.tsx
@@ -2,11 +2,13 @@
import { useTranslations } from 'next-intl';
import { CalendarDays } from 'lucide-react';
-import { purposeStyle } from '@/components/ui/appointments/appointmentPurposeStyles';
import { Card } from '@/components/ui/shared/Card';
import { ScheduleDayPicker } from '@/components/ui/shared/ScheduleDayPicker';
import { startOfLocalDay } from '@/components/appointments/appointmentTime';
-import { treatmentTypeLabelFromCatalog } from '@/components/ui/treatment/treatmentTypeDisplay';
+import {
+ treatmentTypeBannerStyle,
+ treatmentTypeLabelFromCatalog,
+} from '@/components/ui/treatment/treatmentTypeDisplay';
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
import type { TreatmentAppointment } from '@/types/treatment';
@@ -91,8 +93,8 @@ export function AppointmentsStrip({
hour: 'numeric',
minute: '2-digit',
})}`;
- const palette = purposeStyle(a.purpose);
const purposeLabel = treatmentTypeLabelFromCatalog(a.purpose, treatmentCatalog);
+ const purposeIndex = treatmentCatalog.findIndex((e) => e.code === a.purpose);
return (
onSelectAppointment(a.id)}
padding="none"
+ style={treatmentTypeBannerStyle(a.purpose, purposeIndex < 0 ? 0 : purposeIndex)}
className={`
text-left rounded-[var(--radius-sm)] px-3 py-2 min-w-[200px] max-w-[280px] transition-shadow min-h-[52px]
focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/45
- ${palette}
${sel ? 'ring-2 ring-primary ring-offset-2 ring-offset-background-secondary shadow-[inset_0_1px_0_rgba(255,255,255,0.06)]' : 'hover:brightness-110'}
`}
>
diff --git a/frontend/src/components/ui/treatment/TreatmentTypeBadge.tsx b/frontend/src/components/ui/treatment/TreatmentTypeBadge.tsx
index 8f8cc55..61b5b2a 100644
--- a/frontend/src/components/ui/treatment/TreatmentTypeBadge.tsx
+++ b/frontend/src/components/ui/treatment/TreatmentTypeBadge.tsx
@@ -1,7 +1,9 @@
'use client';
-import { purposeStyle } from '@/components/ui/appointments/appointmentPurposeStyles';
-import { formatCodeAsLabel } from '@/components/ui/treatment/treatmentTypeDisplay';
+import {
+ formatCodeAsLabel,
+ treatmentTypeBannerStyle,
+} from '@/components/ui/treatment/treatmentTypeDisplay';
interface TreatmentTypeBadgeProps {
type: string;
@@ -14,7 +16,8 @@ export function TreatmentTypeBadge({ type, label, className = '' }: TreatmentTyp
return (
{display}
diff --git a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx
index b38cb1f..d4ee63f 100644
--- a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx
+++ b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx
@@ -242,6 +242,10 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
const [orgs, setOrgs] = useState([]);
const [labDependentCodes, setLabDependentCodes] = useState>(new Set());
const [treatmentCatalog, setTreatmentCatalog] = useState([]);
+ const treatmentDropdownCatalog = useMemo(
+ () => treatmentCatalog.filter((entry) => entry.availableInTreatment),
+ [treatmentCatalog],
+ );
const [details, setDetails] = useState(() => [newDetail()]);
const [labCaseDrafts, setLabCaseDrafts] = useState([]);
@@ -984,7 +988,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
onDetailsChange={setDetails}
isDetailLocked={isDetailLocked}
labDependentCodes={labDependentCodes}
- treatmentCatalog={treatmentCatalog}
+ treatmentCatalog={treatmentDropdownCatalog}
disabled={!canEditTreatmentForDay}
canEdit={canEdit}
saveStatus={saveStatus}
diff --git a/frontend/src/components/ui/treatment/treatmentTypeDisplay.ts b/frontend/src/components/ui/treatment/treatmentTypeDisplay.ts
index bf48185..bcf90eb 100644
--- a/frontend/src/components/ui/treatment/treatmentTypeDisplay.ts
+++ b/frontend/src/components/ui/treatment/treatmentTypeDisplay.ts
@@ -1,9 +1,19 @@
+import type { CSSProperties } from 'react';
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
+/**
+ * Single source of truth for treatment-type colors across the app
+ * (treatment detail dropdown, appointment booking dropdown, appointment legend,
+ * appointment schedule banners, and the treatment feature's appointment cards).
+ *
+ * There is no universal dental type→color standard (only status-based blue/red
+ * conventions), so this is a curated pastel palette. Extend it as new treatment
+ * types are added; unknown codes fall back to a rotating pastel set by index.
+ */
const TREATMENT_TYPE_COLORS: Record = {
restoration: '#fed7aa',
specialized_restoration: '#fdba74',
- radiography: '#e2e8f0',
+ radiography: '#cbd5e1',
endo: '#fecaca',
surgery: '#fca5a5',
prosthesis: '#c4b5fd',
@@ -11,16 +21,43 @@ const TREATMENT_TYPE_COLORS: Record = {
orthodontics: '#93c5fd',
perio: '#86efac',
pediatrics: '#fde68a',
- extraction: '#f87171',
+ extraction: '#f9a8d4',
clinic_visit: '#bae6fd',
+ continue_treatment: '#99f6e4',
};
-const FALLBACK_COLORS = ['#ddd6fe', '#fed7aa', '#fecaca', '#bae6fd', '#d9f99d'];
+const FALLBACK_COLORS = ['#ddd6fe', '#fed7aa', '#fecaca', '#bae6fd', '#d9f99d', '#fbcfe8'];
+
+/** Dark ink that stays readable on every pastel in the palette. */
+const BANNER_INK = '#14253d';
+/** Dark background used behind pastel option text in native dropdowns. */
+export const DROPDOWN_OPTION_BG = '#14253d';
export function treatmentTypeColor(code: string, index = 0): string {
return TREATMENT_TYPE_COLORS[code] ?? FALLBACK_COLORS[index % FALLBACK_COLORS.length];
}
+/** Filled swatch (legend dots, small indicators). */
+export function treatmentTypeSwatchStyle(code: string, index = 0): CSSProperties {
+ const color = treatmentTypeColor(code, index);
+ return { backgroundColor: color, borderColor: 'rgba(0, 0, 0, 0.18)' };
+}
+
+/** Colored banner / card fill with readable dark text (schedule blocks, appointment cards). */
+export function treatmentTypeBannerStyle(code: string, index = 0): CSSProperties {
+ const color = treatmentTypeColor(code, index);
+ return {
+ backgroundColor: color,
+ borderColor: 'rgba(0, 0, 0, 0.16)',
+ color: BANNER_INK,
+ };
+}
+
+/** Pastel option text on the dark dropdown background. */
+export function treatmentTypeOptionStyle(code: string, index = 0): CSSProperties {
+ return { color: treatmentTypeColor(code, index), backgroundColor: DROPDOWN_OPTION_BG };
+}
+
export function treatmentTypeLabelFromCatalog(
code: string,
catalog: TreatmentCatalogEntry[],
diff --git a/frontend/src/lib/api/treatment-catalog.ts b/frontend/src/lib/api/treatment-catalog.ts
index 001d9fd..f05380d 100644
--- a/frontend/src/lib/api/treatment-catalog.ts
+++ b/frontend/src/lib/api/treatment-catalog.ts
@@ -1,9 +1,15 @@
import { apiClient } from './client';
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
+export type TreatmentCatalogContext = 'appointment' | 'treatment';
+
export const treatmentCatalogApi = {
- list: async (): Promise<{ success: boolean; data: TreatmentCatalogEntry[] }> => {
- const response = await apiClient.get('/treatment-catalog');
+ list: async (
+ context?: TreatmentCatalogContext,
+ ): Promise<{ success: boolean; data: TreatmentCatalogEntry[] }> => {
+ const response = await apiClient.get('/treatment-catalog', {
+ params: context ? { context } : undefined,
+ });
return response.data;
},
};
diff --git a/frontend/src/types/appointment.ts b/frontend/src/types/appointment.ts
index 2435c80..debb4d5 100644
--- a/frontend/src/types/appointment.ts
+++ b/frontend/src/types/appointment.ts
@@ -1,14 +1,11 @@
import type { Patient } from './patient';
-export const APPOINTMENT_PURPOSES = [
- 'consultation',
- 'filling',
- 'endo',
- 'visit',
- 'hygiene',
-] as const;
-
-export type AppointmentPurpose = (typeof APPOINTMENT_PURPOSES)[number];
+/**
+ * An appointment purpose is any treatment-type code selectable in the appointment
+ * context (see the treatment catalog with `?context=appointment`). Kept as a
+ * string so the set is driven by the DB catalog rather than a hardcoded union.
+ */
+export type AppointmentPurpose = string;
export interface AppointmentColumnProvider {
userId: string;
diff --git a/frontend/src/types/treatment-catalog.ts b/frontend/src/types/treatment-catalog.ts
index 51b071a..4797874 100644
--- a/frontend/src/types/treatment-catalog.ts
+++ b/frontend/src/types/treatment-catalog.ts
@@ -4,6 +4,8 @@ export interface TreatmentCatalogEntry {
labDependent: boolean;
sortOrder: number;
label: string;
+ availableInAppointments: boolean;
+ availableInTreatment: boolean;
}
export interface ProsthesisCatalogEntry {
--
2.53.0.windows.1
From cb63ced4e3b960fc51bbc0b136fcb5370147211d Mon Sep 17 00:00:00 2001
From: Admin
Date: Tue, 7 Jul 2026 15:31:09 +0330
Subject: [PATCH 14/17] improvement: tasks and cases feature updated based on
the new prosthesis types and their steps. the whole assignment proccess
removed from the flow.
---
backend/package.json | 3 +-
.../migration.sql | 83 ++++
backend/prisma/regenerate-lab-tasks.ts | 60 +++
backend/prisma/reset-treatment-data.ts | 2 +
backend/prisma/schema.prisma | 65 ++-
backend/src/app.module.ts | 2 +
backend/src/modules/cases/cases.controller.ts | 9 +-
backend/src/modules/cases/cases.service.ts | 149 +++----
backend/src/modules/cases/dto/cases.dto.ts | 15 +-
.../cases/lab-case-task.generator.spec.ts | 32 +-
.../modules/cases/lab-case-task.generator.ts | 44 +-
.../src/modules/cases/lab-case-task.util.ts | 11 +
.../dto/lab-case-comment.dto.ts | 17 +
.../lab-case-comments.controller.ts | 61 +++
.../lab-case-comments.module.ts | 12 +
.../lab-case-comments.service.ts | 183 ++++++++
.../organization/organization.controller.ts | 37 ++
.../organization/organization.module.ts | 3 +-
.../organization/organization.service.ts | 52 +++
backend/src/modules/tasks/dto/tasks.dto.ts | 60 ++-
backend/src/modules/tasks/tasks.controller.ts | 2 +-
backend/src/modules/tasks/tasks.service.ts | 169 ++++++--
frontend/messages/en.json | 65 ++-
frontend/messages/fa.json | 65 ++-
frontend/messages/nl.json | 65 ++-
.../app/[locale]/(dashboard)/cases/page.tsx | 124 +++---
.../app/[locale]/(dashboard)/tasks/page.tsx | 396 +++++++++++++-----
.../ui/lab/LabCaseCommentsPanel.tsx | 164 ++++++++
.../ConnectionCaseHistoryContent.tsx | 58 ++-
.../ui/treatment/TreatmentWorkspace.tsx | 40 +-
.../ui/treatment/prosthesisTypeDisplay.ts | 72 ++++
frontend/src/lib/api/cases.ts | 12 +-
frontend/src/lib/api/organization.ts | 23 +
frontend/src/lib/api/tasks.ts | 40 +-
frontend/src/types/cases.ts | 78 +++-
35 files changed, 1819 insertions(+), 454 deletions(-)
create mode 100644 backend/prisma/migrations/20260707130000_lab_workflow_refactor/migration.sql
create mode 100644 backend/prisma/regenerate-lab-tasks.ts
create mode 100644 backend/src/modules/cases/lab-case-task.util.ts
create mode 100644 backend/src/modules/lab-case-comments/dto/lab-case-comment.dto.ts
create mode 100644 backend/src/modules/lab-case-comments/lab-case-comments.controller.ts
create mode 100644 backend/src/modules/lab-case-comments/lab-case-comments.module.ts
create mode 100644 backend/src/modules/lab-case-comments/lab-case-comments.service.ts
create mode 100644 frontend/src/components/ui/lab/LabCaseCommentsPanel.tsx
create mode 100644 frontend/src/components/ui/treatment/prosthesisTypeDisplay.ts
diff --git a/backend/package.json b/backend/package.json
index ed0e131..25196c6 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -22,7 +22,8 @@
"prisma:migrate": "prisma migrate dev",
"prisma:deploy": "prisma migrate deploy",
"prisma:seed": "prisma db seed",
- "prisma:reset-treatment": "ts-node prisma/reset-treatment-data.ts"
+ "prisma:reset-treatment": "ts-node prisma/reset-treatment-data.ts",
+ "prisma:regenerate-tasks": "ts-node prisma/regenerate-lab-tasks.ts"
},
"prisma": {
"seed": "ts-node prisma/seed.ts"
diff --git a/backend/prisma/migrations/20260707130000_lab_workflow_refactor/migration.sql b/backend/prisma/migrations/20260707130000_lab_workflow_refactor/migration.sql
new file mode 100644
index 0000000..a2e2384
--- /dev/null
+++ b/backend/prisma/migrations/20260707130000_lab_workflow_refactor/migration.sql
@@ -0,0 +1,83 @@
+-- Lab workflow refactor: remove task assignment/priority, group tasks by prosthesis,
+-- add importance flag, status timeline, and per-case comments.
+-- Local dev data only: existing tasks are truncated and regenerated on next dispatch/send.
+
+-- 1. Clear existing task data (task shape changes: tooth -> teeth[]).
+TRUNCATE TABLE "lab_case_tasks" CASCADE;
+
+-- 2. Drop assignment / priority machinery.
+ALTER TABLE "lab_case_tasks" DROP CONSTRAINT IF EXISTS "lab_case_tasks_assigneeUserId_fkey";
+DROP INDEX IF EXISTS "lab_case_tasks_assigneeUserId_priority_createdAt_idx";
+DROP INDEX IF EXISTS "lab_case_tasks_assignedAt_labCaseId_priority_idx";
+DROP INDEX IF EXISTS "lab_case_tasks_labCaseId_treatmentDetailId_tooth_stepOrder_key";
+
+ALTER TABLE "lab_case_tasks"
+ DROP COLUMN IF EXISTS "assigneeUserId",
+ DROP COLUMN IF EXISTS "assignedAt",
+ DROP COLUMN IF EXISTS "priority",
+ DROP COLUMN IF EXISTS "tooth";
+
+-- 3. Rebuild LabTaskStatus enum without PENDING.
+ALTER TABLE "lab_case_tasks" ALTER COLUMN "status" DROP DEFAULT;
+ALTER TYPE "LabTaskStatus" RENAME TO "LabTaskStatus_old";
+CREATE TYPE "LabTaskStatus" AS ENUM ('IN_PROGRESS', 'COMPLETED');
+ALTER TABLE "lab_case_tasks"
+ ALTER COLUMN "status" TYPE "LabTaskStatus" USING ("status"::text::"LabTaskStatus");
+ALTER TABLE "lab_case_tasks" ALTER COLUMN "status" SET DEFAULT 'IN_PROGRESS';
+DROP TYPE "LabTaskStatus_old";
+
+-- 4. New task columns.
+ALTER TABLE "lab_case_tasks" ADD COLUMN "teeth" JSONB NOT NULL DEFAULT '[]';
+ALTER TABLE "lab_case_tasks" ALTER COLUMN "teeth" DROP DEFAULT;
+ALTER TABLE "lab_case_tasks" ADD COLUMN "isImportant" BOOLEAN NOT NULL DEFAULT false;
+ALTER TABLE "lab_case_tasks" ADD COLUMN "lastStatusChangedByUserId" TEXT;
+ALTER TABLE "lab_case_tasks" ADD COLUMN "lastStatusChangedAt" TIMESTAMP(3);
+
+-- 5. New unique + indexes.
+CREATE UNIQUE INDEX "lab_case_tasks_labCaseId_treatmentDetailId_prosthesisTypeCode_stepOrder_key"
+ ON "lab_case_tasks"("labCaseId", "treatmentDetailId", "prosthesisTypeCode", "stepOrder");
+CREATE INDEX "lab_case_tasks_labCaseId_isImportant_idx"
+ ON "lab_case_tasks"("labCaseId", "isImportant");
+
+ALTER TABLE "lab_case_tasks" ADD CONSTRAINT "lab_case_tasks_lastStatusChangedByUserId_fkey"
+ FOREIGN KEY ("lastStatusChangedByUserId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+
+-- 6. Task status timeline.
+CREATE TABLE "lab_case_task_status_events" (
+ "id" TEXT NOT NULL,
+ "taskId" TEXT NOT NULL,
+ "fromStatus" "LabTaskStatus",
+ "toStatus" "LabTaskStatus" NOT NULL,
+ "changedByUserId" TEXT,
+ "changedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT "lab_case_task_status_events_pkey" PRIMARY KEY ("id")
+);
+CREATE INDEX "lab_case_task_status_events_taskId_changedAt_idx"
+ ON "lab_case_task_status_events"("taskId", "changedAt");
+ALTER TABLE "lab_case_task_status_events" ADD CONSTRAINT "lab_case_task_status_events_taskId_fkey"
+ FOREIGN KEY ("taskId") REFERENCES "lab_case_tasks"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+ALTER TABLE "lab_case_task_status_events" ADD CONSTRAINT "lab_case_task_status_events_changedByUserId_fkey"
+ FOREIGN KEY ("changedByUserId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+
+-- 7. Per-case comments.
+CREATE TYPE "LabCaseCommentSide" AS ENUM ('LAB', 'CLINIC');
+CREATE TABLE "lab_case_comments" (
+ "id" TEXT NOT NULL,
+ "labCaseId" TEXT NOT NULL,
+ "authorUserId" TEXT,
+ "authorOrganizationId" TEXT,
+ "authorSide" "LabCaseCommentSide" NOT NULL,
+ "body" TEXT NOT NULL,
+ "visibleToClinic" BOOLEAN NOT NULL DEFAULT false,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+ CONSTRAINT "lab_case_comments_pkey" PRIMARY KEY ("id")
+);
+CREATE INDEX "lab_case_comments_labCaseId_createdAt_idx"
+ ON "lab_case_comments"("labCaseId", "createdAt");
+ALTER TABLE "lab_case_comments" ADD CONSTRAINT "lab_case_comments_labCaseId_fkey"
+ FOREIGN KEY ("labCaseId") REFERENCES "lab_cases"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+ALTER TABLE "lab_case_comments" ADD CONSTRAINT "lab_case_comments_authorUserId_fkey"
+ FOREIGN KEY ("authorUserId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+ALTER TABLE "lab_case_comments" ADD CONSTRAINT "lab_case_comments_authorOrganizationId_fkey"
+ FOREIGN KEY ("authorOrganizationId") REFERENCES "organizations"("id") ON DELETE SET NULL ON UPDATE CASCADE;
diff --git a/backend/prisma/regenerate-lab-tasks.ts b/backend/prisma/regenerate-lab-tasks.ts
new file mode 100644
index 0000000..31cf1ae
--- /dev/null
+++ b/backend/prisma/regenerate-lab-tasks.ts
@@ -0,0 +1,60 @@
+/**
+ * Dev-only: regenerate lab case tasks from existing LabCaseToothProsthesis rows.
+ * Usage: npx ts-node prisma/regenerate-lab-tasks.ts
+ *
+ * The lab workflow refactor truncated lab_case_tasks. This rebuilds task sets
+ * (grouped by treatment detail + prosthesis type, one set per workflow step)
+ * for every already-sent case that still has prosthesis selections.
+ */
+import { PrismaClient } from '@prisma/client';
+import { config } from 'dotenv';
+import path from 'path';
+import { generateLabCaseTasks } from '../src/modules/cases/lab-case-task.generator';
+
+const envPath = path.join(__dirname, '..', '.env');
+config({ path: envPath });
+
+if (process.env.NODE_ENV === 'production') {
+ console.error('regenerate-lab-tasks is not allowed in production');
+ process.exit(1);
+}
+
+const prisma = new PrismaClient();
+
+async function main() {
+ const cases = await prisma.labCase.findMany({
+ where: {
+ sentAt: { not: null },
+ toothProsthesis: { some: {} },
+ },
+ select: {
+ id: true,
+ treatment: { select: { organization: { select: { owner: { select: { language: true } } } } } },
+ },
+ });
+
+ console.log(`Regenerating tasks for ${cases.length} sent case(s)...`);
+
+ let total = 0;
+ for (const labCase of cases) {
+ const locale = labCase.treatment.organization.owner.language ?? 'en';
+ // Clear any stale tasks first so the generator's "already exists" guard passes.
+ await prisma.labCaseTask.deleteMany({ where: { labCaseId: labCase.id } });
+ const created = await prisma.$transaction((tx) =>
+ generateLabCaseTasks(tx, labCase.id, locale),
+ );
+ total += created;
+ console.log(` - ${labCase.id}: ${created} task(s)`);
+ }
+
+ console.log(`Done. ${total} task(s) created.`);
+}
+
+main()
+ .catch((e) => {
+ console.error(e);
+ process.exit(1);
+ })
+ .finally(async () => {
+ await prisma.$disconnect();
+ });
diff --git a/backend/prisma/reset-treatment-data.ts b/backend/prisma/reset-treatment-data.ts
index 6bc6387..1f3bfa5 100644
--- a/backend/prisma/reset-treatment-data.ts
+++ b/backend/prisma/reset-treatment-data.ts
@@ -21,6 +21,8 @@ const prisma = new PrismaClient();
// FK-safe order: children before parents.
const TABLES_IN_ORDER = [
+ 'lab_case_task_status_events',
+ 'lab_case_comments',
'lab_case_tasks',
'lab_case_sends',
'lab_case_tooth_prosthesis',
diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma
index f076995..c317f39 100644
--- a/backend/prisma/schema.prisma
+++ b/backend/prisma/schema.prisma
@@ -23,7 +23,9 @@ model User {
sessions Session[] // 👈 ADD THIS - opposite relation for Session
sentStaffInvites StaffInvitation[]
sentOrganizationInvitations OrganizationInvitation[]
- assignedLabCaseTasks LabCaseTask[] @relation("LabCaseTaskAssignee")
+ statusChangedLabCaseTasks LabCaseTask[] @relation("LabCaseTaskLastStatusChangedBy")
+ labCaseTaskStatusEvents LabCaseTaskStatusEvent[]
+ labCaseComments LabCaseComment[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -65,6 +67,7 @@ model Organization {
appointments Appointment[]
treatments Treatment[]
labCaseSends LabCaseSend[]
+ labCaseComments LabCaseComment[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -115,11 +118,15 @@ model Appointment {
}
enum LabTaskStatus {
- PENDING
IN_PROGRESS
COMPLETED
}
+enum LabCaseCommentSide {
+ LAB
+ CLINIC
+}
+
model Treatment {
id String @id @default(uuid())
organizationId String
@@ -194,6 +201,7 @@ model LabCase {
sends LabCaseSend[]
tasks LabCaseTask[]
toothProsthesis LabCaseToothProsthesis[]
+ comments LabCaseComment[]
@@index([treatmentId, sortOrder])
@@map("lab_cases")
@@ -306,31 +314,66 @@ model LabCaseTask {
id String @id @default(uuid())
labCaseId String
treatmentDetailId String
- tooth String
+ teeth Json
treatmentType String
prosthesisTypeCode String
workflowStepCode String
stepOrder Int
stepLabel String
- assigneeUserId String?
- assignedAt DateTime?
- priority Int @default(3)
- status LabTaskStatus @default(PENDING)
+ isImportant Boolean @default(false)
+ status LabTaskStatus @default(IN_PROGRESS)
+ lastStatusChangedByUserId String?
+ lastStatusChangedAt DateTime?
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)
+ lastStatusChangedBy User? @relation("LabCaseTaskLastStatusChangedBy", fields: [lastStatusChangedByUserId], references: [id], onDelete: SetNull)
+ statusEvents LabCaseTaskStatusEvent[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
- @@unique([labCaseId, treatmentDetailId, tooth, stepOrder])
+ @@unique([labCaseId, treatmentDetailId, prosthesisTypeCode, stepOrder])
@@index([labCaseId, status])
- @@index([assigneeUserId, priority, createdAt])
- @@index([assignedAt, labCaseId, priority])
+ @@index([labCaseId, isImportant])
@@map("lab_case_tasks")
}
+model LabCaseTaskStatusEvent {
+ id String @id @default(uuid())
+ taskId String
+ fromStatus LabTaskStatus?
+ toStatus LabTaskStatus
+ changedByUserId String?
+ changedAt DateTime @default(now())
+
+ task LabCaseTask @relation(fields: [taskId], references: [id], onDelete: Cascade)
+ changedBy User? @relation(fields: [changedByUserId], references: [id], onDelete: SetNull)
+
+ @@index([taskId, changedAt])
+ @@map("lab_case_task_status_events")
+}
+
+model LabCaseComment {
+ id String @id @default(uuid())
+ labCaseId String
+ authorUserId String?
+ authorOrganizationId String?
+ authorSide LabCaseCommentSide
+ body String
+ visibleToClinic Boolean @default(false)
+
+ labCase LabCase @relation(fields: [labCaseId], references: [id], onDelete: Cascade)
+ authorUser User? @relation(fields: [authorUserId], references: [id], onDelete: SetNull)
+ authorOrganization Organization? @relation(fields: [authorOrganizationId], references: [id], onDelete: SetNull)
+
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ @@index([labCaseId, createdAt])
+ @@map("lab_case_comments")
+}
+
model Plan {
id String @id @default(uuid())
name String @unique // "Solo", "Small", "Medium", "Large", "Enterprise"
diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts
index 6c2aab6..11390d4 100644
--- a/backend/src/app.module.ts
+++ b/backend/src/app.module.ts
@@ -16,6 +16,7 @@ import { TasksModule } from './modules/tasks/tasks.module';
import { TreatmentCatalogModule } from './modules/treatment-catalog/treatment-catalog.module';
import { CatalogModule } from './modules/catalog/catalog.module';
import { ProsthesisCatalogModule } from './modules/prosthesis-catalog/prosthesis-catalog.module';
+import { LabCaseCommentsModule } from './modules/lab-case-comments/lab-case-comments.module';
@Module({
imports: [
@@ -33,6 +34,7 @@ import { ProsthesisCatalogModule } from './modules/prosthesis-catalog/prosthesis
TreatmentsModule,
CasesModule,
TasksModule,
+ LabCaseCommentsModule,
StaffModule,
OrganizationModule,
AdminModule.forRoot(),
diff --git a/backend/src/modules/cases/cases.controller.ts b/backend/src/modules/cases/cases.controller.ts
index 58ec144..00ecae5 100644
--- a/backend/src/modules/cases/cases.controller.ts
+++ b/backend/src/modules/cases/cases.controller.ts
@@ -35,13 +35,6 @@ export class CasesController {
return this.casesService.listFilterOptions(organizationId, req.user.id);
}
- @Get('assignable-members')
- @ApiOperation({ summary: 'List lab staff who can be assigned to tasks' })
- listAssignableMembers(@Req() req) {
- 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) {
@@ -50,7 +43,7 @@ export class CasesController {
}
@Patch(':id/tasks/:taskId')
- @ApiOperation({ summary: 'Update task assignee or priority' })
+ @ApiOperation({ summary: 'Toggle task important flag' })
updateTask(
@Param('id') id: string,
@Param('taskId') taskId: string,
diff --git a/backend/src/modules/cases/cases.service.ts b/backend/src/modules/cases/cases.service.ts
index 733a478..f873ccb 100644
--- a/backend/src/modules/cases/cases.service.ts
+++ b/backend/src/modules/cases/cases.service.ts
@@ -14,6 +14,7 @@ import {
import { TreatmentCatalogService } from '../treatment-catalog/treatment-catalog.service';
import { normalizeTeeth } from '../treatments/treatment.utils';
import { ListLabCasesDto, UpdateLabCaseTaskDto } from './dto/cases.dto';
+import { normalizeTaskTeeth } from './lab-case-task.util';
const labCaseListInclude = {
treatment: {
@@ -41,16 +42,29 @@ const labCaseListInclude = {
},
tasks: {
orderBy: [
- { tooth: 'asc' as const },
- { treatmentType: 'asc' as const },
+ { treatmentDetailId: 'asc' as const },
+ { prosthesisTypeCode: 'asc' as const },
{ stepOrder: 'asc' as const },
],
include: {
- assignee: { select: { id: true, name: true, email: true } },
+ lastStatusChangedBy: { select: { id: true, name: true } },
+ statusEvents: {
+ orderBy: { changedAt: 'asc' as const },
+ include: { changedBy: { select: { id: true, name: true } } },
+ },
},
},
} satisfies Prisma.LabCaseInclude;
+type LabCaseTaskWithRelations = Prisma.LabCaseTaskGetPayload<{
+ include: {
+ lastStatusChangedBy: { select: { id: true; name: true } };
+ statusEvents: {
+ include: { changedBy: { select: { id: true; name: true } } };
+ };
+ };
+}>;
+
@Injectable()
export class CasesService {
constructor(
@@ -294,23 +308,15 @@ export class CasesService {
throw new NotFoundException('Task not found');
}
- if (dto.assigneeUserId !== undefined && dto.assigneeUserId !== null) {
- await this.ensureAssignableMember(dto.assigneeUserId, labOrganizationId);
- }
-
const updated = await this.prisma.labCaseTask.update({
where: { id: taskId },
- data: {
- ...(dto.assigneeUserId !== undefined
- ? {
- assigneeUserId: dto.assigneeUserId,
- assignedAt: dto.assigneeUserId === null ? null : new Date(),
- }
- : {}),
- ...(dto.priority !== undefined ? { priority: dto.priority } : {}),
- },
+ data: { isImportant: dto.isImportant },
include: {
- assignee: { select: { id: true, name: true, email: true } },
+ lastStatusChangedBy: { select: { id: true, name: true } },
+ statusEvents: {
+ orderBy: { changedAt: 'asc' },
+ include: { changedBy: { select: { id: true, name: true } } },
+ },
},
});
@@ -324,35 +330,6 @@ export class CasesService {
return { success: true, data: this.mapTask(updated, prosthesisLabels) };
}
- 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 } },
- permissions: { include: { permission: true } },
- },
- orderBy: [{ isOwner: 'desc' }, { createdAt: 'asc' }],
- });
-
- return {
- success: true,
- data: memberships
- .filter((m) => {
- if (m.isOwner) return true;
- const names = m.permissions.map((p) => p.permission.name);
- return names.includes('TAB_TASKS_READ') || names.includes('TAB_TASKS_EDIT');
- })
- .map((m) => ({
- userId: m.user.id,
- name: m.user.name,
- email: m.user.email,
- isOwner: m.isOwner,
- })),
- };
- }
-
private buildListWhere(
labOrganizationId: string,
query: ListLabCasesDto,
@@ -465,7 +442,7 @@ export class CasesService {
prosthesisCodes,
locale,
);
- const tasksByTooth = this.groupTasksByTooth(lc.tasks, prosthesisLabels);
+ const tasksByTooth = this.groupTasks(lc.tasks, prosthesisLabels);
return {
id: lc.id,
@@ -495,27 +472,15 @@ export class CasesService {
};
}
- private groupTasksByTooth(
- tasks: Array<{
- id: string;
- tooth: string;
- treatmentType: string;
- prosthesisTypeCode: string;
- stepOrder: number;
- stepLabel: string;
- status: LabTaskStatus;
- priority: number;
- assigneeUserId: string | null;
- assignedAt: Date | null;
- createdAt: Date;
- assignee: { id: string; name: string; email: string } | null;
- }>,
+ private groupTasks(
+ tasks: LabCaseTaskWithRelations[],
prosthesisLabels: Map,
) {
const groups = new Map<
string,
{
- tooth: string;
+ treatmentDetailId: string;
+ teeth: string[];
treatmentType: string;
prosthesisTypeCode: string;
prosthesisTypeLabel: string;
@@ -524,9 +489,10 @@ export class CasesService {
>();
for (const task of tasks) {
- const key = `${task.tooth}:${task.treatmentType}:${task.prosthesisTypeCode}`;
+ const key = `${task.treatmentDetailId}:${task.prosthesisTypeCode}`;
const entry = groups.get(key) ?? {
- tooth: task.tooth,
+ treatmentDetailId: task.treatmentDetailId,
+ teeth: normalizeTaskTeeth(task.teeth),
treatmentType: task.treatmentType,
prosthesisTypeCode: task.prosthesisTypeCode,
prosthesisTypeLabel:
@@ -541,26 +507,13 @@ export class CasesService {
}
private mapTask(
- task: {
- id: string;
- tooth: string;
- treatmentType: string;
- prosthesisTypeCode: string;
- workflowStepCode?: string;
- stepOrder: number;
- stepLabel: string;
- status: LabTaskStatus;
- priority: number;
- assigneeUserId: string | null;
- assignedAt: Date | null;
- createdAt: Date;
- assignee: { id: string; name: string; email: string } | null;
- },
+ task: LabCaseTaskWithRelations,
prosthesisLabels: Map,
) {
return {
id: task.id,
- tooth: task.tooth,
+ treatmentDetailId: task.treatmentDetailId,
+ teeth: normalizeTaskTeeth(task.teeth),
treatmentType: task.treatmentType,
prosthesisTypeCode: task.prosthesisTypeCode,
prosthesisTypeLabel:
@@ -569,32 +522,24 @@ export class CasesService {
stepOrder: task.stepOrder,
stepLabel: task.stepLabel,
status: task.status,
- priority: task.priority,
- assignedAt: task.assignedAt?.toISOString() ?? null,
+ isImportant: task.isImportant,
createdAt: task.createdAt.toISOString(),
- assigneeUserId: task.assigneeUserId,
- assignee: task.assignee
- ? { id: task.assignee.id, name: task.assignee.name, email: task.assignee.email }
+ lastStatusChangedAt: task.lastStatusChangedAt?.toISOString() ?? null,
+ lastStatusChangedBy: task.lastStatusChangedBy
+ ? { id: task.lastStatusChangedBy.id, name: task.lastStatusChangedBy.name }
: null,
+ timeline: task.statusEvents.map((event) => ({
+ id: event.id,
+ fromStatus: event.fromStatus,
+ toStatus: event.toStatus,
+ changedAt: event.changedAt.toISOString(),
+ changedBy: event.changedBy
+ ? { id: event.changedBy.id, name: event.changedBy.name }
+ : null,
+ })),
};
}
- private async ensureAssignableMember(userId: string, labOrganizationId: string) {
- const membership = await this.prisma.membership.findFirst({
- where: { userId, organizationId: labOrganizationId, isActive: true },
- include: { permissions: { include: { permission: true } } },
- });
- if (!membership) {
- throw new BadRequestException('Assignee must be an active member of this lab');
- }
- if (membership.isOwner) return;
- const names = membership.permissions.map((p) => p.permission.name);
- if (names.includes('TAB_TASKS_READ') || names.includes('TAB_TASKS_EDIT')) {
- return;
- }
- throw new BadRequestException('Assignee must have access to the Tasks tab');
- }
-
private async assertCanReadCases(userId: string, organizationId: string) {
const m = await this.getMembership(userId, organizationId);
if (!m) {
diff --git a/backend/src/modules/cases/dto/cases.dto.ts b/backend/src/modules/cases/dto/cases.dto.ts
index e7ca9bb..6e567fd 100644
--- a/backend/src/modules/cases/dto/cases.dto.ts
+++ b/backend/src/modules/cases/dto/cases.dto.ts
@@ -1,18 +1,9 @@
import { Transform } from 'class-transformer';
-import { IsDateString, IsInt, IsOptional, IsString, IsUUID, Max, Min, ValidateIf } from 'class-validator';
+import { IsBoolean, IsDateString, IsInt, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator';
export class UpdateLabCaseTaskDto {
- @IsOptional()
- @ValidateIf((_, value) => value !== null)
- @IsUUID()
- assigneeUserId?: string | null;
-
- @IsOptional()
- @Transform(({ value }) => Number(value))
- @IsInt()
- @Min(1)
- @Max(5)
- priority?: number;
+ @IsBoolean()
+ isImportant: boolean;
}
export class ListLabCasesDto {
diff --git a/backend/src/modules/cases/lab-case-task.generator.spec.ts b/backend/src/modules/cases/lab-case-task.generator.spec.ts
index 56d7350..31abb0e 100644
--- a/backend/src/modules/cases/lab-case-task.generator.spec.ts
+++ b/backend/src/modules/cases/lab-case-task.generator.spec.ts
@@ -101,10 +101,11 @@ describe('generateLabCaseTasks', () => {
expect(count).toBe(pfmSteps.length);
expect(created).toHaveLength(pfmSteps.length);
expect(created[0]).toMatchObject({
- tooth: '14',
+ teeth: ['14'],
prosthesisTypeCode: 'pfm_crown',
workflowStepCode: 'intraoral_scan',
stepLabel: 'Intraoral Scan',
+ status: 'IN_PROGRESS',
});
const stepCodes = (created as Array<{ workflowStepCode: string }>).map(
(row) => row.workflowStepCode,
@@ -115,6 +116,35 @@ describe('generateLabCaseTasks', () => {
expect(stepCodes).toContain('milling_wet');
});
+ it('groups teeth sharing a prosthesis in one detail, and keeps other prosthesis separate', async () => {
+ const pfmSteps = stepsFromSeed('pfm_crown');
+ const zirconiaSteps = stepsFromSeed('monolithic_zirconia');
+ const { tx, created } = buildMockTx({
+ toothProsthesisRows: [
+ { treatmentDetailId: 'detail-1', tooth: '15', prosthesisTypeCode: 'pfm_crown' },
+ { treatmentDetailId: 'detail-1', tooth: '14', prosthesisTypeCode: 'pfm_crown' },
+ { treatmentDetailId: 'detail-1', tooth: '16', prosthesisTypeCode: 'monolithic_zirconia' },
+ ],
+ prosthesisTypes: [
+ { code: 'pfm_crown', steps: pfmSteps },
+ { code: 'monolithic_zirconia', steps: zirconiaSteps },
+ ],
+ });
+
+ const count = await generateLabCaseTasks(tx as never, 'lab-case-group', 'en');
+
+ expect(count).toBe(pfmSteps.length + zirconiaSteps.length);
+ const rows = created as Array<{ teeth: string[]; prosthesisTypeCode: string }>;
+ const pfmRows = rows.filter((r) => r.prosthesisTypeCode === 'pfm_crown');
+ const zirconiaRows = rows.filter((r) => r.prosthesisTypeCode === 'monolithic_zirconia');
+
+ expect(pfmRows).toHaveLength(pfmSteps.length);
+ expect(zirconiaRows).toHaveLength(zirconiaSteps.length);
+ // Teeth sharing the prosthesis in the same detail are merged and sorted.
+ expect(pfmRows.every((r) => JSON.stringify(r.teeth) === JSON.stringify(['14', '15']))).toBe(true);
+ expect(zirconiaRows.every((r) => JSON.stringify(r.teeth) === JSON.stringify(['16']))).toBe(true);
+ });
+
it('omits packing and shipping for smile_design', async () => {
const smileSteps = stepsFromSeed('smile_design');
const { tx, created } = buildMockTx({
diff --git a/backend/src/modules/cases/lab-case-task.generator.ts b/backend/src/modules/cases/lab-case-task.generator.ts
index bac6c3e..3279291 100644
--- a/backend/src/modules/cases/lab-case-task.generator.ts
+++ b/backend/src/modules/cases/lab-case-task.generator.ts
@@ -58,25 +58,46 @@ export async function generateLabCaseTasks(
const stepLabels = await resolveStepLabels(tx, allStepCodes, locale);
- const taskRows: Prisma.LabCaseTaskCreateManyInput[] = [];
+ // Group teeth that share the same (treatment detail + prosthesis type): one task set
+ // per group, with each step covering every tooth in that group.
+ const groups = new Map<
+ string,
+ { treatmentDetailId: string; treatmentType: string; prosthesisTypeCode: string; teeth: string[] }
+ >();
for (const row of toothProsthesisRows) {
- const typeSteps = stepsByProsthesisCode.get(row.prosthesisTypeCode) ?? [];
+ const key = `${row.treatmentDetailId}::${row.prosthesisTypeCode}`;
+ const group = groups.get(key) ?? {
+ treatmentDetailId: row.treatmentDetailId,
+ treatmentType: row.detail.treatmentType,
+ prosthesisTypeCode: row.prosthesisTypeCode,
+ teeth: [],
+ };
+ group.teeth.push(row.tooth);
+ groups.set(key, group);
+ }
+
+ const taskRows: Prisma.LabCaseTaskCreateManyInput[] = [];
+
+ for (const group of groups.values()) {
+ const typeSteps = stepsByProsthesisCode.get(group.prosthesisTypeCode) ?? [];
if (typeSteps.length === 0) {
continue;
}
+ const teeth = sortTeeth(group.teeth);
+
for (const step of typeSteps) {
taskRows.push({
labCaseId,
- treatmentDetailId: row.treatmentDetailId,
- tooth: row.tooth,
- treatmentType: row.detail.treatmentType,
- prosthesisTypeCode: row.prosthesisTypeCode,
+ treatmentDetailId: group.treatmentDetailId,
+ teeth,
+ treatmentType: group.treatmentType,
+ prosthesisTypeCode: group.prosthesisTypeCode,
workflowStepCode: step.workflowStepCode,
stepOrder: step.stepOrder,
stepLabel: stepLabels.get(step.workflowStepCode) ?? step.workflowStepCode,
- status: LabTaskStatus.PENDING,
+ status: LabTaskStatus.IN_PROGRESS,
});
}
}
@@ -89,6 +110,15 @@ export async function generateLabCaseTasks(
return taskRows.length;
}
+function sortTeeth(teeth: string[]): string[] {
+ return [...new Set(teeth)].sort((a, b) => {
+ const na = Number(a);
+ const nb = Number(b);
+ if (!Number.isNaN(na) && !Number.isNaN(nb)) return na - nb;
+ return a.localeCompare(b);
+ });
+}
+
async function resolveStepLabels(
tx: TransactionClient,
stepCodes: string[],
diff --git a/backend/src/modules/cases/lab-case-task.util.ts b/backend/src/modules/cases/lab-case-task.util.ts
new file mode 100644
index 0000000..b445412
--- /dev/null
+++ b/backend/src/modules/cases/lab-case-task.util.ts
@@ -0,0 +1,11 @@
+import { Prisma } from '@prisma/client';
+
+/** Normalize the JSON `teeth` column of a lab case task into a clean string[]. */
+export function normalizeTaskTeeth(value: Prisma.JsonValue | null | undefined): string[] {
+ if (!Array.isArray(value)) {
+ return [];
+ }
+ return value
+ .filter((v): v is string | number => typeof v === 'string' || typeof v === 'number')
+ .map((v) => String(v));
+}
diff --git a/backend/src/modules/lab-case-comments/dto/lab-case-comment.dto.ts b/backend/src/modules/lab-case-comments/dto/lab-case-comment.dto.ts
new file mode 100644
index 0000000..773f158
--- /dev/null
+++ b/backend/src/modules/lab-case-comments/dto/lab-case-comment.dto.ts
@@ -0,0 +1,17 @@
+import { IsBoolean, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
+
+export class CreateLabCaseCommentDto {
+ @IsString()
+ @MinLength(1)
+ @MaxLength(2000)
+ body: string;
+
+ @IsOptional()
+ @IsBoolean()
+ visibleToClinic?: boolean;
+}
+
+export class SetCommentVisibilityDto {
+ @IsBoolean()
+ visibleToClinic: boolean;
+}
diff --git a/backend/src/modules/lab-case-comments/lab-case-comments.controller.ts b/backend/src/modules/lab-case-comments/lab-case-comments.controller.ts
new file mode 100644
index 0000000..0328c03
--- /dev/null
+++ b/backend/src/modules/lab-case-comments/lab-case-comments.controller.ts
@@ -0,0 +1,61 @@
+import {
+ Body,
+ Controller,
+ Get,
+ Param,
+ Patch,
+ Post,
+ 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 {
+ CreateLabCaseCommentDto,
+ SetCommentVisibilityDto,
+} from './dto/lab-case-comment.dto';
+import { LabCaseCommentsService } from './lab-case-comments.service';
+
+@ApiTags('case-comments')
+@ApiBearerAuth('JWT-auth')
+@UseGuards(JwtAuthGuard, LabOrgGuard)
+@Controller('case-comments')
+export class LabCaseCommentsController {
+ constructor(private readonly service: LabCaseCommentsService) {}
+
+ private orgId(req: { user: { organizationId?: string } }) {
+ return req.user.organizationId as string;
+ }
+
+ @Get(':caseId')
+ @ApiOperation({ summary: 'List comments for a lab case (lab side)' })
+ list(@Param('caseId') caseId: string, @Req() req) {
+ return this.service.listForLab(caseId, this.orgId(req), req.user.id);
+ }
+
+ @Post(':caseId')
+ @ApiOperation({ summary: 'Add a comment to a lab case (lab side)' })
+ add(
+ @Param('caseId') caseId: string,
+ @Body() dto: CreateLabCaseCommentDto,
+ @Req() req,
+ ) {
+ return this.service.addForLab(caseId, this.orgId(req), req.user.id, dto);
+ }
+
+ @Patch('item/:commentId/visibility')
+ @ApiOperation({ summary: 'Toggle whether a comment is visible to the clinic' })
+ setVisibility(
+ @Param('commentId') commentId: string,
+ @Body() dto: SetCommentVisibilityDto,
+ @Req() req,
+ ) {
+ return this.service.setVisibility(
+ commentId,
+ this.orgId(req),
+ req.user.id,
+ dto.visibleToClinic,
+ );
+ }
+}
diff --git a/backend/src/modules/lab-case-comments/lab-case-comments.module.ts b/backend/src/modules/lab-case-comments/lab-case-comments.module.ts
new file mode 100644
index 0000000..e6f8abd
--- /dev/null
+++ b/backend/src/modules/lab-case-comments/lab-case-comments.module.ts
@@ -0,0 +1,12 @@
+import { Module } from '@nestjs/common';
+import { PrismaService } from '../../../prisma/prisma.service';
+import { LabOrgGuard } from '../../common/guards/lab-org.guard';
+import { LabCaseCommentsController } from './lab-case-comments.controller';
+import { LabCaseCommentsService } from './lab-case-comments.service';
+
+@Module({
+ controllers: [LabCaseCommentsController],
+ providers: [LabCaseCommentsService, PrismaService, LabOrgGuard],
+ exports: [LabCaseCommentsService],
+})
+export class LabCaseCommentsModule {}
diff --git a/backend/src/modules/lab-case-comments/lab-case-comments.service.ts b/backend/src/modules/lab-case-comments/lab-case-comments.service.ts
new file mode 100644
index 0000000..a1a04c9
--- /dev/null
+++ b/backend/src/modules/lab-case-comments/lab-case-comments.service.ts
@@ -0,0 +1,183 @@
+import {
+ ForbiddenException,
+ Injectable,
+ NotFoundException,
+} from '@nestjs/common';
+import { LabCaseCommentSide, Prisma } from '@prisma/client';
+import { PrismaService } from '../../../prisma/prisma.service';
+import { CreateLabCaseCommentDto } from './dto/lab-case-comment.dto';
+
+const commentInclude = {
+ authorUser: { select: { id: true, name: true } },
+ authorOrganization: { select: { id: true, name: true } },
+} satisfies Prisma.LabCaseCommentInclude;
+
+type CommentWithRelations = Prisma.LabCaseCommentGetPayload<{
+ include: typeof commentInclude;
+}>;
+
+@Injectable()
+export class LabCaseCommentsService {
+ constructor(private readonly prisma: PrismaService) {}
+
+ // ---------- Lab side (TAB_TASKS_EDIT) ----------
+
+ async listForLab(caseId: string, labOrganizationId: string, actorUserId: string) {
+ await this.assertLabCanComment(caseId, labOrganizationId, actorUserId);
+ const comments = await this.fetchComments(caseId);
+ return { success: true, data: comments.map((c) => this.mapComment(c, LabCaseCommentSide.LAB)) };
+ }
+
+ async addForLab(
+ caseId: string,
+ labOrganizationId: string,
+ actorUserId: string,
+ dto: CreateLabCaseCommentDto,
+ ) {
+ await this.assertLabCanComment(caseId, labOrganizationId, actorUserId);
+ const created = await this.prisma.labCaseComment.create({
+ data: {
+ labCaseId: caseId,
+ authorUserId: actorUserId,
+ authorOrganizationId: labOrganizationId,
+ authorSide: LabCaseCommentSide.LAB,
+ body: dto.body.trim(),
+ visibleToClinic: dto.visibleToClinic ?? false,
+ },
+ include: commentInclude,
+ });
+ return { success: true, data: this.mapComment(created, LabCaseCommentSide.LAB) };
+ }
+
+ async setVisibility(
+ commentId: string,
+ labOrganizationId: string,
+ actorUserId: string,
+ visibleToClinic: boolean,
+ ) {
+ const comment = await this.prisma.labCaseComment.findUnique({
+ where: { id: commentId },
+ select: { id: true, labCaseId: true, authorSide: true },
+ });
+ if (!comment) {
+ throw new NotFoundException('Comment not found');
+ }
+ await this.assertLabCanComment(comment.labCaseId, labOrganizationId, actorUserId);
+ if (comment.authorSide !== LabCaseCommentSide.LAB) {
+ throw new ForbiddenException('Only lab comments can change visibility');
+ }
+ const updated = await this.prisma.labCaseComment.update({
+ where: { id: commentId },
+ data: { visibleToClinic },
+ include: commentInclude,
+ });
+ return { success: true, data: this.mapComment(updated, LabCaseCommentSide.LAB) };
+ }
+
+ // ---------- Clinic side (connection access is validated by caller) ----------
+
+ async listForClinic(caseId: string, clinicOrganizationId: string) {
+ await this.assertClinicOwnsCase(caseId, clinicOrganizationId);
+ const comments = await this.fetchComments(caseId, { visibleOnly: true });
+ return {
+ success: true,
+ data: comments.map((c) => this.mapComment(c, LabCaseCommentSide.CLINIC)),
+ };
+ }
+
+ async addForClinic(
+ caseId: string,
+ clinicOrganizationId: string,
+ actorUserId: string,
+ dto: CreateLabCaseCommentDto,
+ ) {
+ await this.assertClinicOwnsCase(caseId, clinicOrganizationId);
+ const created = await this.prisma.labCaseComment.create({
+ data: {
+ labCaseId: caseId,
+ authorUserId: actorUserId,
+ authorOrganizationId: clinicOrganizationId,
+ authorSide: LabCaseCommentSide.CLINIC,
+ body: dto.body.trim(),
+ // Clinic-authored comments are inherently visible to the clinic.
+ visibleToClinic: true,
+ },
+ include: commentInclude,
+ });
+ return { success: true, data: this.mapComment(created, LabCaseCommentSide.CLINIC) };
+ }
+
+ // ---------- Helpers ----------
+
+ private fetchComments(caseId: string, opts?: { visibleOnly?: boolean }) {
+ return this.prisma.labCaseComment.findMany({
+ where: {
+ labCaseId: caseId,
+ ...(opts?.visibleOnly ? { visibleToClinic: true } : {}),
+ },
+ include: commentInclude,
+ orderBy: { createdAt: 'asc' },
+ });
+ }
+
+ private mapComment(comment: CommentWithRelations, viewerSide: LabCaseCommentSide) {
+ return {
+ id: comment.id,
+ body: comment.body,
+ authorSide: comment.authorSide,
+ authorName: comment.authorUser?.name ?? null,
+ authorOrganizationName: comment.authorOrganization?.name ?? null,
+ visibleToClinic: comment.visibleToClinic,
+ createdAt: comment.createdAt.toISOString(),
+ // Only lab viewers can toggle visibility, and only on lab-authored comments.
+ canToggleVisibility:
+ viewerSide === LabCaseCommentSide.LAB &&
+ comment.authorSide === LabCaseCommentSide.LAB,
+ };
+ }
+
+ private async assertLabCanComment(
+ caseId: string,
+ labOrganizationId: string,
+ actorUserId: string,
+ ) {
+ const labCase = await this.prisma.labCase.findFirst({
+ where: {
+ id: caseId,
+ sentAt: { not: null },
+ sends: { some: { organizationId: labOrganizationId } },
+ },
+ select: { id: true },
+ });
+ if (!labCase) {
+ throw new NotFoundException('Case not found');
+ }
+
+ const membership = await this.prisma.membership.findFirst({
+ where: { userId: actorUserId, organizationId: labOrganizationId, isActive: true },
+ include: { permissions: { include: { permission: true } } },
+ });
+ if (!membership) {
+ throw new ForbiddenException('You are not a member of this organization');
+ }
+ if (membership.isOwner) return;
+ const names = membership.permissions.map((p) => p.permission.name);
+ if (!names.includes('TAB_TASKS_EDIT')) {
+ throw new ForbiddenException('You do not have access to task comments');
+ }
+ }
+
+ private async assertClinicOwnsCase(caseId: string, clinicOrganizationId: string) {
+ const labCase = await this.prisma.labCase.findFirst({
+ where: {
+ id: caseId,
+ sentAt: { not: null },
+ treatment: { organizationId: clinicOrganizationId },
+ },
+ select: { id: true },
+ });
+ if (!labCase) {
+ throw new NotFoundException('Case not found');
+ }
+ }
+}
diff --git a/backend/src/modules/organization/organization.controller.ts b/backend/src/modules/organization/organization.controller.ts
index 9f3ff7d..548dd3c 100644
--- a/backend/src/modules/organization/organization.controller.ts
+++ b/backend/src/modules/organization/organization.controller.ts
@@ -19,6 +19,7 @@ import { PreviewOrganizationInviteDto } from './dto/preview-organization-invite.
import { RespondConnectionRequestDto } from './dto/respond-connection-request.dto';
import { OrganizationService } from './organization.service';
import { ListLabCasesDto } from '../cases/dto/cases.dto';
+import { CreateLabCaseCommentDto } from '../lab-case-comments/dto/lab-case-comment.dto';
/**
* Counterpart orgs (clinic↔lab).
@@ -157,6 +158,42 @@ export class OrganizationController {
);
}
+ @Get('connections/:connectionId/cases/:caseId/comments')
+ @UseGuards(JwtAuthGuard)
+ @ApiOperation({ summary: 'List clinic-visible comments for a connection case' })
+ listConnectionCaseComments(
+ @Req() req: { user: { id: string; organizationId?: string } },
+ @Param('connectionId') connectionId: string,
+ @Param('caseId') caseId: string,
+ ) {
+ const organizationId = this.organizationService.getOrganizationIdFromUser(req.user);
+ return this.organizationService.listConnectionCaseComments(
+ req.user.id,
+ organizationId,
+ connectionId,
+ caseId,
+ );
+ }
+
+ @Post('connections/:connectionId/cases/:caseId/comments')
+ @UseGuards(JwtAuthGuard)
+ @ApiOperation({ summary: 'Reply to a connection case as the clinic' })
+ addConnectionCaseComment(
+ @Req() req: { user: { id: string; organizationId?: string } },
+ @Param('connectionId') connectionId: string,
+ @Param('caseId') caseId: string,
+ @Body() dto: CreateLabCaseCommentDto,
+ ) {
+ const organizationId = this.organizationService.getOrganizationIdFromUser(req.user);
+ return this.organizationService.addConnectionCaseComment(
+ req.user.id,
+ organizationId,
+ connectionId,
+ caseId,
+ dto,
+ );
+ }
+
@Post('invitations/:invitationId/link')
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: 'Get a shareable invite link for a pending invitation' })
diff --git a/backend/src/modules/organization/organization.module.ts b/backend/src/modules/organization/organization.module.ts
index b8626c4..18a7d93 100644
--- a/backend/src/modules/organization/organization.module.ts
+++ b/backend/src/modules/organization/organization.module.ts
@@ -1,11 +1,12 @@
import { Module } from '@nestjs/common';
import { PrismaService } from '../../../prisma/prisma.service';
import { CasesModule } from '../cases/cases.module';
+import { LabCaseCommentsModule } from '../lab-case-comments/lab-case-comments.module';
import { OrganizationController } from './organization.controller';
import { OrganizationService } from './organization.service';
@Module({
- imports: [CasesModule],
+ imports: [CasesModule, LabCaseCommentsModule],
controllers: [OrganizationController],
providers: [OrganizationService, PrismaService],
})
diff --git a/backend/src/modules/organization/organization.service.ts b/backend/src/modules/organization/organization.service.ts
index 6abb5b1..65b2184 100644
--- a/backend/src/modules/organization/organization.service.ts
+++ b/backend/src/modules/organization/organization.service.ts
@@ -11,6 +11,8 @@ import { createHash, randomBytes } from 'crypto';
import { PrismaService } from '../../../prisma/prisma.service';
import { ListLabCasesDto } from '../cases/dto/cases.dto';
import { CasesService } from '../cases/cases.service';
+import { LabCaseCommentsService } from '../lab-case-comments/lab-case-comments.service';
+import { CreateLabCaseCommentDto } from '../lab-case-comments/dto/lab-case-comment.dto';
import { AcceptOrganizationInviteDto } from './dto/accept-organization-invite.dto';
import { CreateConnectionRequestDto } from './dto/create-connection-request.dto';
import { InviteOrganizationDto } from './dto/invite-organization.dto';
@@ -33,6 +35,7 @@ export class OrganizationService {
constructor(
private readonly prisma: PrismaService,
private readonly casesService: CasesService,
+ private readonly commentsService: LabCaseCommentsService,
) {}
getOrganizationIdFromUser(user: { organizationId?: string }) {
@@ -405,6 +408,55 @@ export class OrganizationService {
};
}
+ async listConnectionCaseComments(
+ userId: string,
+ organizationId: string,
+ connectionId: string,
+ caseId: string,
+ ) {
+ const { clinicOrganizationId } = await this.resolveClinicConnection(
+ userId,
+ organizationId,
+ connectionId,
+ );
+ return this.commentsService.listForClinic(caseId, clinicOrganizationId);
+ }
+
+ async addConnectionCaseComment(
+ userId: string,
+ organizationId: string,
+ connectionId: string,
+ caseId: string,
+ dto: CreateLabCaseCommentDto,
+ ) {
+ const { clinicOrganizationId } = await this.resolveClinicConnection(
+ userId,
+ organizationId,
+ connectionId,
+ );
+ return this.commentsService.addForClinic(caseId, clinicOrganizationId, userId, dto);
+ }
+
+ /**
+ * Clinic comment surfaces require the actor to belong to the clinic side of the connection.
+ * Only clinic-side members may read/reply to case comments from the connection history.
+ */
+ private async resolveClinicConnection(
+ userId: string,
+ organizationId: string,
+ connectionId: string,
+ ) {
+ const actor = await this.getActorMembership(userId, organizationId);
+ if (!actor || !this.canEditOrganizations(actor)) {
+ throw new ForbiddenException('You do not have permission to manage organizations');
+ }
+ const parties = await this.resolveActiveConnectionParties(connectionId, organizationId, actor);
+ if (parties.clinicOrganizationId !== organizationId) {
+ throw new ForbiddenException('Only the clinic can comment on this case');
+ }
+ return parties;
+ }
+
/** Re-issue a shareable URL for a pending invitation (rotates token; previous URL stops working). */
async getInvitationLink(userId: string, organizationId: string, invitationId: string) {
const actor = await this.getActorMembership(userId, organizationId);
diff --git a/backend/src/modules/tasks/dto/tasks.dto.ts b/backend/src/modules/tasks/dto/tasks.dto.ts
index 1b5539d..9898378 100644
--- a/backend/src/modules/tasks/dto/tasks.dto.ts
+++ b/backend/src/modules/tasks/dto/tasks.dto.ts
@@ -1,13 +1,71 @@
-import { IsEnum, IsInt, IsOptional, Max, Min } from 'class-validator';
+import {
+ IsBoolean,
+ IsDateString,
+ IsEnum,
+ IsIn,
+ IsInt,
+ IsOptional,
+ IsString,
+ IsUUID,
+ Max,
+ Min,
+} from 'class-validator';
import { Transform } from 'class-transformer';
import { LabTaskStatus } from '@prisma/client';
+const toBoolean = ({ value }: { value: unknown }) => {
+ if (typeof value === 'boolean') return value;
+ if (value === 'true' || value === '1') return true;
+ if (value === 'false' || value === '0') return false;
+ return value;
+};
+
export class UpdateLabTaskDto {
@IsEnum(LabTaskStatus)
status: LabTaskStatus;
}
+export type TaskSortField = 'date' | 'status' | 'clinic' | 'patient' | 'important';
+
export class ListLabTasksDto {
+ @IsOptional()
+ @IsString()
+ q?: string;
+
+ @IsOptional()
+ @IsUUID()
+ clinicOrganizationId?: string;
+
+ @IsOptional()
+ @IsEnum(LabTaskStatus)
+ status?: LabTaskStatus;
+
+ @IsOptional()
+ @Transform(toBoolean)
+ @IsBoolean()
+ completed?: boolean;
+
+ @IsOptional()
+ @Transform(toBoolean)
+ @IsBoolean()
+ important?: boolean;
+
+ @IsOptional()
+ @IsDateString()
+ sentFrom?: string;
+
+ @IsOptional()
+ @IsDateString()
+ sentTo?: string;
+
+ @IsOptional()
+ @IsIn(['date', 'status', 'clinic', 'patient', 'important'])
+ sortBy?: TaskSortField;
+
+ @IsOptional()
+ @IsIn(['asc', 'desc'])
+ sortDir?: 'asc' | 'desc';
+
@IsOptional()
@Transform(({ value }) => Number(value))
@IsInt()
diff --git a/backend/src/modules/tasks/tasks.controller.ts b/backend/src/modules/tasks/tasks.controller.ts
index 0cce614..279d86c 100644
--- a/backend/src/modules/tasks/tasks.controller.ts
+++ b/backend/src/modules/tasks/tasks.controller.ts
@@ -13,7 +13,7 @@ export class TasksController {
constructor(private readonly tasksService: TasksService) {}
@Get()
- @ApiOperation({ summary: 'List lab tasks (owner: all, staff: assigned only)' })
+ @ApiOperation({ summary: 'List lab tasks' })
list(@Query() query: ListLabTasksDto, @Req() req) {
const organizationId = this.tasksService.getOrganizationIdFromUser(req.user);
return this.tasksService.list(organizationId, req.user.id, query, req.user.language);
diff --git a/backend/src/modules/tasks/tasks.service.ts b/backend/src/modules/tasks/tasks.service.ts
index 66fd7c4..9c15b51 100644
--- a/backend/src/modules/tasks/tasks.service.ts
+++ b/backend/src/modules/tasks/tasks.service.ts
@@ -6,14 +6,16 @@ import {
} from '@nestjs/common';
import { CatalogEntityKind, LabTaskStatus, Prisma } from '@prisma/client';
import { PrismaService } from '../../../prisma/prisma.service';
+import { normalizeMobile } from '../../common/phone';
import {
CatalogLabelService,
normalizeCatalogLocale,
} from '../catalog/catalog-label.service';
+import { normalizeTaskTeeth } from '../cases/lab-case-task.util';
import { ListLabTasksDto, UpdateLabTaskDto } from './dto/tasks.dto';
const taskListInclude = {
- assignee: { select: { id: true, name: true, email: true } },
+ lastStatusChangedBy: { select: { id: true, name: true } },
labCase: {
include: {
treatment: {
@@ -48,35 +50,17 @@ export class TasksService {
) {
await this.assertCanReadTasks(actorUserId, labOrganizationId);
- const membership = await this.getMembership(actorUserId, labOrganizationId);
- if (!membership) {
- throw new ForbiddenException('You are not a member of this organization');
- }
-
const page = query.page ?? 1;
const limit = Math.min(Math.max(query.limit ?? 50, 1), 100);
const skip = (page - 1) * limit;
- const where: Prisma.LabCaseTaskWhereInput = {
- labCase: {
- sentAt: { not: null },
- sends: { some: { organizationId: labOrganizationId } },
- },
- ...(membership.isOwner ? {} : { assigneeUserId: actorUserId }),
- };
+ const where = this.buildListWhere(labOrganizationId, query);
const [items, total] = await Promise.all([
this.prisma.labCaseTask.findMany({
where,
include: taskListInclude,
- orderBy: [
- { assignedAt: { sort: 'desc', nulls: 'first' } },
- { createdAt: 'desc' },
- { labCaseId: 'asc' },
- { priority: 'desc' },
- { stepOrder: 'asc' },
- { id: 'asc' },
- ],
+ orderBy: this.buildOrderBy(query),
skip,
take: limit,
}),
@@ -114,11 +98,6 @@ export class TasksService {
) {
await this.assertCanEditTasks(actorUserId, labOrganizationId);
- const membership = await this.getMembership(actorUserId, labOrganizationId);
- if (!membership) {
- throw new ForbiddenException('You are not a member of this organization');
- }
-
const task = await this.prisma.labCaseTask.findFirst({
where: {
id: taskId,
@@ -134,14 +113,29 @@ export class TasksService {
throw new NotFoundException('Task not found');
}
- if (!membership.isOwner && task.assigneeUserId !== actorUserId) {
- throw new ForbiddenException('You can only update tasks assigned to you');
- }
+ const updated = await this.prisma.$transaction(async (tx) => {
+ const result = await tx.labCaseTask.update({
+ where: { id: taskId },
+ data: {
+ status: dto.status,
+ lastStatusChangedByUserId: actorUserId,
+ lastStatusChangedAt: new Date(),
+ },
+ include: taskListInclude,
+ });
- const updated = await this.prisma.labCaseTask.update({
- where: { id: taskId },
- data: { status: dto.status },
- include: taskListInclude,
+ if (task.status !== dto.status) {
+ await tx.labCaseTaskStatusEvent.create({
+ data: {
+ taskId,
+ fromStatus: task.status,
+ toStatus: dto.status,
+ changedByUserId: actorUserId,
+ },
+ });
+ }
+
+ return result;
});
const locale = normalizeCatalogLocale(localeInput);
@@ -154,6 +148,100 @@ export class TasksService {
return { success: true, data: this.mapTaskListItem(updated, prosthesisLabels) };
}
+ private buildListWhere(
+ labOrganizationId: string,
+ query: ListLabTasksDto,
+ ): Prisma.LabCaseTaskWhereInput {
+ const sentAtFilter: Prisma.DateTimeNullableFilter = { not: null };
+
+ if (query.sentFrom) {
+ const from = new Date(query.sentFrom);
+ if (Number.isNaN(from.getTime())) {
+ throw new BadRequestException('Invalid sentFrom date');
+ }
+ sentAtFilter.gte = from;
+ }
+ if (query.sentTo) {
+ const to = new Date(query.sentTo);
+ if (Number.isNaN(to.getTime())) {
+ throw new BadRequestException('Invalid sentTo date');
+ }
+ to.setHours(23, 59, 59, 999);
+ sentAtFilter.lte = to;
+ }
+
+ // Status: explicit status wins; completed=true/false narrows; otherwise no status filter.
+ let status: LabTaskStatus | undefined;
+ if (query.status) {
+ status = query.status;
+ } else if (query.completed === true) {
+ status = LabTaskStatus.COMPLETED;
+ } else if (query.completed === false) {
+ status = LabTaskStatus.IN_PROGRESS;
+ }
+
+ return {
+ labCase: {
+ sentAt: sentAtFilter,
+ sends: { some: { organizationId: labOrganizationId } },
+ ...(query.clinicOrganizationId
+ ? { treatment: { organizationId: query.clinicOrganizationId } }
+ : {}),
+ ...(query.q?.trim() ? { treatment: this.buildSearchWhere(query.q.trim()) } : {}),
+ },
+ ...(status !== undefined ? { status } : {}),
+ ...(query.important !== undefined ? { isImportant: query.important } : {}),
+ };
+ }
+
+ private buildSearchWhere(q: string): Prisma.TreatmentWhereInput {
+ const orConditions: Prisma.PatientWhereInput[] = [
+ { firstName: { contains: q, mode: 'insensitive' } },
+ { lastName: { contains: q, mode: 'insensitive' } },
+ ];
+ const normalized = normalizeMobile(q);
+ if (normalized) {
+ orConditions.push({ mobile: normalized });
+ }
+ return {
+ OR: [
+ { patient: { OR: orConditions } },
+ { organization: { name: { contains: q, mode: 'insensitive' } } },
+ ],
+ };
+ }
+
+ private buildOrderBy(query: ListLabTasksDto): Prisma.LabCaseTaskOrderByWithRelationInput[] {
+ const dir = query.sortDir ?? 'desc';
+ switch (query.sortBy) {
+ case 'status':
+ return [{ status: dir }, { createdAt: 'desc' }, { id: 'asc' }];
+ case 'clinic':
+ return [
+ { labCase: { treatment: { organization: { name: dir } } } },
+ { createdAt: 'desc' },
+ { id: 'asc' },
+ ];
+ case 'patient':
+ return [
+ { labCase: { treatment: { patient: { lastName: dir } } } },
+ { labCase: { treatment: { patient: { firstName: dir } } } },
+ { id: 'asc' },
+ ];
+ case 'important':
+ return [{ isImportant: dir }, { createdAt: 'desc' }, { id: 'asc' }];
+ case 'date':
+ default:
+ return [
+ { labCase: { sentAt: dir } },
+ { labCaseId: 'asc' },
+ { treatmentDetailId: 'asc' },
+ { stepOrder: 'asc' },
+ { id: 'asc' },
+ ];
+ }
+ }
+
private mapTaskListItem(
task: Prisma.LabCaseTaskGetPayload<{ include: typeof taskListInclude }>,
prosthesisLabels: Map,
@@ -161,21 +249,22 @@ export class TasksService {
return {
id: task.id,
labCaseId: task.labCaseId,
- tooth: task.tooth,
+ treatmentDetailId: task.treatmentDetailId,
+ teeth: normalizeTaskTeeth(task.teeth),
treatmentType: task.treatmentType,
prosthesisTypeCode: task.prosthesisTypeCode,
prosthesisTypeLabel:
prosthesisLabels.get(task.prosthesisTypeCode) ?? task.prosthesisTypeCode,
+ workflowStepCode: task.workflowStepCode,
stepOrder: task.stepOrder,
stepLabel: task.stepLabel,
status: task.status,
- priority: task.priority,
- assignedAt: task.assignedAt?.toISOString() ?? null,
- createdAt: task.createdAt.toISOString(),
- assigneeUserId: task.assigneeUserId,
- assignee: task.assignee
- ? { id: task.assignee.id, name: task.assignee.name, email: task.assignee.email }
+ isImportant: task.isImportant,
+ lastStatusChangedAt: task.lastStatusChangedAt?.toISOString() ?? null,
+ lastStatusChangedBy: task.lastStatusChangedBy
+ ? { id: task.lastStatusChangedBy.id, name: task.lastStatusChangedBy.name }
: null,
+ createdAt: task.createdAt.toISOString(),
clinic: task.labCase.treatment.organization,
patient: {
id: task.labCase.treatment.patient.id,
diff --git a/frontend/messages/en.json b/frontend/messages/en.json
index 93c9679..c1eafb9 100644
--- a/frontend/messages/en.json
+++ b/frontend/messages/en.json
@@ -319,7 +319,7 @@
},
"cases": {
"title": "Cases",
- "subtitle": "Lab cases sent from linked clinics. Assign tasks and track progress by tooth.",
+ "subtitle": "Lab cases sent from linked clinics. Track progress and flag important tasks.",
"searchPlaceholder": "Search by patient name or mobile…",
"emptyList": "No cases received yet.",
"selectCaseHint": "Select a case from the list to view tasks.",
@@ -329,13 +329,17 @@
"taskProgressShort": "{progress} tasks",
"treatmentDetails": "Treatment details",
"teethLabel": "Teeth",
- "tasksByTooth": "Tasks by tooth",
- "toothGroupTitle": "Tooth {tooth} · {prosthesis} · {type}",
+ "tasksByTooth": "Tasks",
+ "toothGroupTitle": "Teeth {teeth} · {prosthesis}",
"noTasks": "No tasks were generated for this case.",
- "unassigned": "Unassigned",
- "statusPending": "Pending",
"statusInProgress": "In progress",
"statusCompleted": "Completed",
+ "importantLabel": "Important",
+ "markImportant": "Mark as important",
+ "lastUpdatedBy": "Updated by {name}",
+ "lastUpdatedUnknown": "Not started yet",
+ "timelineTitle": "History",
+ "timelineEntry": "{status} · {name} · {date}",
"errorLoadList": "Failed to load cases.",
"errorLoadDetail": "Failed to load case details.",
"errorUpdateTask": "Failed to update task.",
@@ -351,32 +355,63 @@
"prevPage": "Previous",
"nextPage": "Next",
"pageSummary": "Page {page} of {totalPages} ({total} cases)",
- "priorityLabel": "Priority",
"statusLabel": "Status"
},
"tasks": {
"title": "Tasks",
- "subtitle": "Your assigned lab tasks. Update status as you work through each step.",
- "subtitleOwner": "All lab tasks in the organization. Assign tasks from Cases; update status on your own assignments here.",
+ "subtitle": "All lab tasks from connected clinics. Filter, sort, and update the status of each step.",
+ "subtitleOwner": "All lab tasks from connected clinics. Filter, sort, and update the status of each step.",
"loading": "Loading tasks…",
- "emptyList": "No tasks assigned to you yet.",
- "emptyListOwner": "No tasks in the lab inbox yet.",
+ "emptyList": "No tasks match the current filters.",
+ "emptyListOwner": "No tasks match the current filters.",
"noPermissionTitle": "Tasks",
"noPermissionBody": "You do not have permission to view tasks for this organization.",
"fromClinic": "From {name}",
"patientLabel": "Patient",
"taskDate": "{date}",
- "priorityLabel": "Priority {n}",
- "toothLabel": "Tooth {tooth}",
- "unassigned": "Unassigned",
- "assignedTo": "Assigned to {name}",
- "statusPending": "Pending",
+ "teethLabel": "Teeth {teeth}",
+ "importantBadge": "Important",
+ "lastUpdatedBy": "Updated by {name}",
"statusInProgress": "In progress",
"statusCompleted": "Completed",
+ "searchPlaceholder": "Search patient or clinic…",
+ "filterClinic": "Clinic",
+ "filterClinicAll": "All clinics",
+ "filterStatus": "Status",
+ "filterStatusAll": "All statuses",
+ "showCompleted": "Show completed",
+ "importantOnly": "Important only",
+ "filterSentFrom": "From",
+ "filterSentTo": "To",
+ "sortBy": "Sort by",
+ "sortDate": "Date",
+ "sortStatus": "Status",
+ "sortClinic": "Clinic",
+ "sortPatient": "Patient",
+ "sortImportant": "Important",
+ "clearFilters": "Clear filters",
+ "commentsButton": "Comments",
"errorLoadList": "Failed to load tasks.",
"errorUpdateTask": "Failed to update task.",
"pageSummary": "Page {page} of {totalPages} ({total} tasks)"
},
+ "caseComments": {
+ "title": "Comments",
+ "placeholder": "Write a comment…",
+ "reply": "Reply…",
+ "post": "Post",
+ "empty": "No comments yet.",
+ "visibleToClinicToggle": "Visible to clinic",
+ "clinicCanSee": "Clinic can see this",
+ "hiddenFromClinic": "Hidden from clinic",
+ "makeVisible": "Make visible to clinic",
+ "makeHidden": "Hide from clinic",
+ "labAuthor": "Lab",
+ "clinicAuthor": "Clinic",
+ "errorLoad": "Failed to load comments.",
+ "errorPost": "Failed to post comment.",
+ "errorToggle": "Failed to update comment visibility."
+ },
"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 388c300..5c78203 100644
--- a/frontend/messages/fa.json
+++ b/frontend/messages/fa.json
@@ -319,7 +319,7 @@
},
"cases": {
"title": "پروندهها",
- "subtitle": "پروندههای ارسالی از کلینیکهای متصل. وظایف را تخصیص دهید و پیشرفت هر دندان را پیگیری کنید.",
+ "subtitle": "پروندههای ارسالی از کلینیکهای متصل. پیشرفت را پیگیری کنید و وظایف مهم را علامت بزنید.",
"searchPlaceholder": "جستجو با نام یا موبایل بیمار…",
"emptyList": "هنوز پروندهای دریافت نشده است.",
"selectCaseHint": "برای مشاهده وظایف، یک پرونده از فهرست انتخاب کنید.",
@@ -329,13 +329,17 @@
"taskProgressShort": "{progress} وظیفه",
"treatmentDetails": "جزئیات درمان",
"teethLabel": "دندانها",
- "tasksByTooth": "وظایف به تفکیک دندان",
- "toothGroupTitle": "دندان {tooth} · {prosthesis} · {type}",
+ "tasksByTooth": "وظایف",
+ "toothGroupTitle": "دندانهای {teeth} · {prosthesis}",
"noTasks": "برای این پرونده وظیفهای ایجاد نشده است.",
- "unassigned": "بدون مسئول",
- "statusPending": "در انتظار",
"statusInProgress": "در حال انجام",
"statusCompleted": "انجام شده",
+ "importantLabel": "مهم",
+ "markImportant": "علامتگذاری به عنوان مهم",
+ "lastUpdatedBy": "بهروزرسانی توسط {name}",
+ "lastUpdatedUnknown": "هنوز شروع نشده",
+ "timelineTitle": "تاریخچه",
+ "timelineEntry": "{status} · {name} · {date}",
"errorLoadList": "بارگذاری پروندهها ناموفق بود.",
"errorLoadDetail": "بارگذاری جزئیات پرونده ناموفق بود.",
"errorUpdateTask": "بهروزرسانی وظیفه ناموفق بود.",
@@ -351,32 +355,63 @@
"prevPage": "قبلی",
"nextPage": "بعدی",
"pageSummary": "صفحه {page} از {totalPages} ({total} پرونده)",
- "priorityLabel": "اولویت",
"statusLabel": "وضعیت"
},
"tasks": {
"title": "وظایف",
- "subtitle": "وظایف لاب اختصاصیافته به شما. وضعیت را در حین انجام هر مرحله بهروز کنید.",
- "subtitleOwner": "همه وظایف لاب در سازمان. تخصیص از بخش پروندهها؛ بهروزرسانی وضعیت برای وظایف خودتان اینجا.",
+ "subtitle": "همه وظایف لاب از کلینیکهای متصل. فیلتر، مرتبسازی و بهروزرسانی وضعیت هر مرحله.",
+ "subtitleOwner": "همه وظایف لاب از کلینیکهای متصل. فیلتر، مرتبسازی و بهروزرسانی وضعیت هر مرحله.",
"loading": "در حال بارگذاری وظایف…",
- "emptyList": "هنوز وظیفهای به شما اختصاص داده نشده است.",
- "emptyListOwner": "هنوز وظیفهای در صندوق ورودی لاب وجود ندارد.",
+ "emptyList": "هیچ وظیفهای با فیلترهای فعلی مطابقت ندارد.",
+ "emptyListOwner": "هیچ وظیفهای با فیلترهای فعلی مطابقت ندارد.",
"noPermissionTitle": "وظایف",
"noPermissionBody": "شما مجوز مشاهده وظایف برای این سازمان را ندارید.",
"fromClinic": "از {name}",
"patientLabel": "بیمار",
"taskDate": "{date}",
- "priorityLabel": "اولویت {n}",
- "toothLabel": "دندان {tooth}",
- "unassigned": "اختصاص داده نشده",
- "assignedTo": "اختصاص به {name}",
- "statusPending": "در انتظار",
+ "teethLabel": "دندانهای {teeth}",
+ "importantBadge": "مهم",
+ "lastUpdatedBy": "بهروزرسانی توسط {name}",
"statusInProgress": "در حال انجام",
"statusCompleted": "تکمیلشده",
+ "searchPlaceholder": "جستجوی بیمار یا کلینیک…",
+ "filterClinic": "کلینیک",
+ "filterClinicAll": "همه کلینیکها",
+ "filterStatus": "وضعیت",
+ "filterStatusAll": "همه وضعیتها",
+ "showCompleted": "نمایش تکمیلشدهها",
+ "importantOnly": "فقط مهمها",
+ "filterSentFrom": "از",
+ "filterSentTo": "تا",
+ "sortBy": "مرتبسازی بر اساس",
+ "sortDate": "تاریخ",
+ "sortStatus": "وضعیت",
+ "sortClinic": "کلینیک",
+ "sortPatient": "بیمار",
+ "sortImportant": "مهم",
+ "clearFilters": "پاک کردن فیلترها",
+ "commentsButton": "نظرات",
"errorLoadList": "بارگذاری وظایف ناموفق بود.",
"errorUpdateTask": "بهروزرسانی وظیفه ناموفق بود.",
"pageSummary": "صفحه {page} از {totalPages} ({total} وظیفه)"
},
+ "caseComments": {
+ "title": "نظرات",
+ "placeholder": "یک نظر بنویسید…",
+ "reply": "پاسخ…",
+ "post": "ثبت",
+ "empty": "هنوز نظری ثبت نشده است.",
+ "visibleToClinicToggle": "قابل مشاهده برای کلینیک",
+ "clinicCanSee": "کلینیک میتواند ببیند",
+ "hiddenFromClinic": "پنهان از کلینیک",
+ "makeVisible": "نمایش به کلینیک",
+ "makeHidden": "پنهان از کلینیک",
+ "labAuthor": "آزمایشگاه",
+ "clinicAuthor": "کلینیک",
+ "errorLoad": "بارگذاری نظرات ناموفق بود.",
+ "errorPost": "ثبت نظر ناموفق بود.",
+ "errorToggle": "بهروزرسانی وضعیت نمایش نظر ناموفق بود."
+ },
"appointments": {
"title": "نوبتها",
"subtitle": "یک بیمار را جستجو کنید، تاریخ را انتخاب کنید، سپس روی یک زمان در زیر ارائهدهنده کلیک کنید تا رزرو کنید.",
diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json
index ab69c7a..6fa45d2 100644
--- a/frontend/messages/nl.json
+++ b/frontend/messages/nl.json
@@ -319,7 +319,7 @@
},
"cases": {
"title": "Dossiers",
- "subtitle": "Labdossiers van gekoppelde klinieken. Wijs taken toe en volg de voortgang per tand.",
+ "subtitle": "Labdossiers van gekoppelde klinieken. Volg de voortgang en markeer belangrijke taken.",
"searchPlaceholder": "Zoeken op patiëntnaam of mobiel…",
"emptyList": "Nog geen dossiers ontvangen.",
"selectCaseHint": "Selecteer een dossier uit de lijst om taken te bekijken.",
@@ -329,13 +329,17 @@
"taskProgressShort": "{progress} taken",
"treatmentDetails": "Behandeldetails",
"teethLabel": "Tanden",
- "tasksByTooth": "Taken per tand",
- "toothGroupTitle": "Tand {tooth} · {prosthesis} · {type}",
+ "tasksByTooth": "Taken",
+ "toothGroupTitle": "Tanden {teeth} · {prosthesis}",
"noTasks": "Er zijn geen taken gegenereerd voor dit dossier.",
- "unassigned": "Niet toegewezen",
- "statusPending": "In afwachting",
"statusInProgress": "Bezig",
"statusCompleted": "Voltooid",
+ "importantLabel": "Belangrijk",
+ "markImportant": "Markeren als belangrijk",
+ "lastUpdatedBy": "Bijgewerkt door {name}",
+ "lastUpdatedUnknown": "Nog niet gestart",
+ "timelineTitle": "Geschiedenis",
+ "timelineEntry": "{status} · {name} · {date}",
"errorLoadList": "Dossiers laden mislukt.",
"errorLoadDetail": "Dossierdetails laden mislukt.",
"errorUpdateTask": "Taak bijwerken mislukt.",
@@ -351,32 +355,63 @@
"prevPage": "Vorige",
"nextPage": "Volgende",
"pageSummary": "Pagina {page} van {totalPages} ({total} dossiers)",
- "priorityLabel": "Prioriteit",
"statusLabel": "Status"
},
"tasks": {
"title": "Taken",
- "subtitle": "Uw toegewezen labtaken. Werk de status bij terwijl u elke stap uitvoert.",
- "subtitleOwner": "Alle labtaken in de organisatie. Wijs toe via Dossiers; werk hier de status bij voor uw eigen taken.",
+ "subtitle": "Alle labtaken van gekoppelde klinieken. Filter, sorteer en werk de status van elke stap bij.",
+ "subtitleOwner": "Alle labtaken van gekoppelde klinieken. Filter, sorteer en werk de status van elke stap bij.",
"loading": "Taken laden…",
- "emptyList": "Nog geen taken aan u toegewezen.",
- "emptyListOwner": "Nog geen taken in de lab-inbox.",
+ "emptyList": "Geen taken komen overeen met de huidige filters.",
+ "emptyListOwner": "Geen taken komen overeen met de huidige filters.",
"noPermissionTitle": "Taken",
"noPermissionBody": "U heeft geen toestemming om taken voor deze organisatie te bekijken.",
"fromClinic": "Van {name}",
"patientLabel": "Patiënt",
"taskDate": "{date}",
- "priorityLabel": "Prioriteit {n}",
- "toothLabel": "Tand {tooth}",
- "unassigned": "Niet toegewezen",
- "assignedTo": "Toegewezen aan {name}",
- "statusPending": "In afwachting",
+ "teethLabel": "Tanden {teeth}",
+ "importantBadge": "Belangrijk",
+ "lastUpdatedBy": "Bijgewerkt door {name}",
"statusInProgress": "Bezig",
"statusCompleted": "Voltooid",
+ "searchPlaceholder": "Zoek patiënt of kliniek…",
+ "filterClinic": "Kliniek",
+ "filterClinicAll": "Alle klinieken",
+ "filterStatus": "Status",
+ "filterStatusAll": "Alle statussen",
+ "showCompleted": "Voltooide tonen",
+ "importantOnly": "Alleen belangrijk",
+ "filterSentFrom": "Vanaf",
+ "filterSentTo": "Tot",
+ "sortBy": "Sorteren op",
+ "sortDate": "Datum",
+ "sortStatus": "Status",
+ "sortClinic": "Kliniek",
+ "sortPatient": "Patiënt",
+ "sortImportant": "Belangrijk",
+ "clearFilters": "Filters wissen",
+ "commentsButton": "Opmerkingen",
"errorLoadList": "Taken laden mislukt.",
"errorUpdateTask": "Taak bijwerken mislukt.",
"pageSummary": "Pagina {page} van {totalPages} ({total} taken)"
},
+ "caseComments": {
+ "title": "Opmerkingen",
+ "placeholder": "Schrijf een opmerking…",
+ "reply": "Antwoorden…",
+ "post": "Plaatsen",
+ "empty": "Nog geen opmerkingen.",
+ "visibleToClinicToggle": "Zichtbaar voor kliniek",
+ "clinicCanSee": "Kliniek kan dit zien",
+ "hiddenFromClinic": "Verborgen voor kliniek",
+ "makeVisible": "Zichtbaar maken voor kliniek",
+ "makeHidden": "Verbergen voor kliniek",
+ "labAuthor": "Lab",
+ "clinicAuthor": "Kliniek",
+ "errorLoad": "Opmerkingen laden mislukt.",
+ "errorPost": "Opmerking plaatsen mislukt.",
+ "errorToggle": "Zichtbaarheid bijwerken mislukt."
+ },
"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
index 31bf060..c97b264 100644
--- a/frontend/src/app/[locale]/(dashboard)/cases/page.tsx
+++ b/frontend/src/app/[locale]/(dashboard)/cases/page.tsx
@@ -17,16 +17,18 @@ import { FORM_SELECT_CLASS } from '@/components/ui/shared/formSelectStyles';
import { SearchBar } from '@/components/ui/shared/SearchBar';
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
import type {
- AssignableMember,
CasesFilterOptions,
LabCaseDetail,
LabCaseListItem,
LabTaskStatus,
PaginatedLabCases,
} from '@/types/cases';
+import {
+ formatToothList,
+ prosthesisTypeBadgeStyle,
+} from '@/components/ui/treatment/prosthesisTypeDisplay';
const PAGE_SIZE = 20;
-const PRIORITY_OPTIONS = [1, 2, 3, 4, 5] as const;
function taskStatusVariant(status: LabTaskStatus): BadgeVariant {
switch (status) {
@@ -100,7 +102,6 @@ export default function CasesPage() {
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);
@@ -115,7 +116,6 @@ export default function CasesPage() {
const statusOptions: { value: LabTaskStatus; label: string }[] = useMemo(
() => [
- { value: 'PENDING', label: t('statusPending') },
{ value: 'IN_PROGRESS', label: t('statusInProgress') },
{ value: 'COMPLETED', label: t('statusCompleted') },
],
@@ -171,7 +171,6 @@ export default function CasesPage() {
useEffect(() => {
void casesApi.listFilterOptions().then((r) => setFilterOptions(r.data)).catch(() => {});
- void casesApi.listAssignableMembers().then((r) => setMembers(r.data)).catch(() => {});
void treatmentCatalogApi.list().then((r) => setTreatmentCatalog(r.data)).catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only initial fetch
}, []);
@@ -216,25 +215,14 @@ export default function CasesPage() {
setPage(1);
}
- async function handleTaskUpdate(
- taskId: string,
- payload: { assigneeUserId?: string | null; priority?: number },
- ) {
+ async function handleImportantToggle(taskId: string, isImportant: boolean) {
if (!selectedCaseId || !canEdit) return;
setUpdatingTaskId(taskId);
toast.setError('');
try {
- await casesApi.updateTask(selectedCaseId, taskId, payload);
+ await casesApi.setTaskImportant(selectedCaseId, taskId, isImportant);
await loadDetail(selectedCaseId);
- await loadCases({
- q: search,
- clinicOrganizationId: clinicId,
- treatmentType,
- sentFrom,
- sentTo,
- page,
- });
} catch (error: unknown) {
toast.showError(formatApiErrorMessage(error, t('errorUpdateTask')));
} finally {
@@ -476,65 +464,65 @@ export default function CasesPage() {
{selectedCase.tasksByTooth.length === 0 ? (
{t('noTasks')}
) : (
- selectedCase.tasksByTooth.map((group) => (
+ selectedCase.tasksByTooth.map((group, groupIndex) => (
-
- {t('toothGroupTitle', {
- tooth: group.tooth,
- prosthesis: group.prosthesisTypeLabel,
- type: treatmentLabel(group.treatmentType),
- })}
+
+
+ {group.prosthesisTypeLabel}
+
+
+ {t('toothGroupTitle', {
+ teeth: formatToothList(group.teeth),
+ prosthesis: group.prosthesisTypeLabel,
+ })}
+
diff --git a/frontend/src/app/[locale]/(dashboard)/tasks/page.tsx b/frontend/src/app/[locale]/(dashboard)/tasks/page.tsx
index 9ef5763..1a9a7e4 100644
--- a/frontend/src/app/[locale]/(dashboard)/tasks/page.tsx
+++ b/frontend/src/app/[locale]/(dashboard)/tasks/page.tsx
@@ -1,12 +1,19 @@
'use client';
-import { useEffect, useMemo, useRef, useState } from 'react';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslations } from 'next-intl';
+import { MessageSquare } from 'lucide-react';
import { ToastStack } from '@/components/ui/shared/Toast';
import { Badge, type BadgeVariant } from '@/components/ui/shared/Badge';
import { Button } from '@/components/ui/shared/Button';
import { FORM_SELECT_CLASS } from '@/components/ui/shared/formSelectStyles';
+import { SearchBar } from '@/components/ui/shared/SearchBar';
import { TreatmentTypeBadge } from '@/components/ui/treatment/TreatmentTypeBadge';
+import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel';
+import {
+ formatToothList,
+ prosthesisTypeBadgeStyle,
+} from '@/components/ui/treatment/prosthesisTypeDisplay';
import { formatApiErrorMessage } from '@/components/shared/formatApiError';
import { canEditTasks, canViewTasks } from '@/components/shared/permissions';
import { useAuth } from '@/lib/hooks/useAuth';
@@ -14,20 +21,19 @@ import { useToast } from '@/lib/hooks/useToast';
import { tasksApi } from '@/lib/api/tasks';
import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
import { treatmentTypeLabelFromCatalog } from '@/components/ui/treatment/treatmentTypeDisplay';
-import type { LabTaskListItem, LabTaskStatus, PaginatedLabTasks } from '@/types/cases';
+import type {
+ LabTaskListItem,
+ LabTaskStatus,
+ ListLabTasksParams,
+ PaginatedLabTasks,
+ TaskSortField,
+} from '@/types/cases';
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
const PAGE_SIZE = 50;
function taskStatusVariant(status: LabTaskStatus): BadgeVariant {
- switch (status) {
- case 'COMPLETED':
- return 'success';
- case 'IN_PROGRESS':
- return 'default';
- default:
- return 'warning';
- }
+ return status === 'COMPLETED' ? 'success' : 'default';
}
function formatPatientName(patient: { firstName: string; lastName: string }) {
@@ -50,64 +56,94 @@ export default function TasksPage() {
const [loading, setLoading] = useState(false);
const [updatingTaskId, setUpdatingTaskId] = useState
(null);
const [treatmentCatalog, setTreatmentCatalog] = useState([]);
+ const [expandedCommentsCaseId, setExpandedCommentsCaseId] = useState(null);
+
+ const [search, setSearch] = useState('');
+ const [clinicId, setClinicId] = useState('');
+ const [statusFilter, setStatusFilter] = useState<'' | LabTaskStatus>('');
+ const [showCompleted, setShowCompleted] = useState(false);
+ const [importantOnly, setImportantOnly] = useState(false);
+ const [sentFrom, setSentFrom] = useState('');
+ const [sentTo, setSentTo] = useState('');
+ const [sortBy, setSortBy] = useState('date');
+ const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
const canView = canViewTasks(currentOrganization);
const canEdit = canEditTasks(currentOrganization);
const locale = user?.language ?? 'en';
- const isOwner = Boolean(currentOrganization?.isOwner);
const tRef = useRef(t);
tRef.current = t;
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 listParams = useMemo((): ListLabTasksParams => {
+ const params: ListLabTasksParams = {
+ page,
+ limit: PAGE_SIZE,
+ sortBy,
+ sortDir,
+ };
+ if (search.trim()) params.q = search.trim();
+ if (clinicId) params.clinicOrganizationId = clinicId;
+ if (statusFilter) {
+ params.status = statusFilter;
+ } else if (showCompleted) {
+ params.completed = undefined;
+ } else {
+ params.completed = false;
+ }
+ if (importantOnly) params.important = true;
+ if (sentFrom) params.sentFrom = sentFrom;
+ if (sentTo) params.sentTo = sentTo;
+ return params;
+ }, [page, search, clinicId, statusFilter, showCompleted, importantOnly, sentFrom, sentTo, sortBy, sortDir]);
+
+ const clinicOptions = useMemo(() => {
+ const map = new Map();
+ for (const task of tasks) {
+ map.set(task.clinic.id, task.clinic.name);
+ }
+ return [...map.entries()].map(([id, name]) => ({ id, name }));
+ }, [tasks]);
+
+ const loadTasks = useCallback(async () => {
+ setLoading(true);
+ setError('');
+ try {
+ const response = await tasksApi.list(listParams);
+ setTasks(response.data.items);
+ setPagination(response.data.pagination);
+ } catch (error: unknown) {
+ showError(formatApiErrorMessage(error, tRef.current('errorLoadList')));
+ } finally {
+ setLoading(false);
+ }
+ }, [listParams, showError, setError]);
+
useEffect(() => {
void treatmentCatalogApi.list().then((r) => setTreatmentCatalog(r.data)).catch(() => {});
}, []);
useEffect(() => {
if (!canView) return;
-
- let cancelled = false;
-
- void (async () => {
- setLoading(true);
- setError('');
- try {
- const response = await tasksApi.list({ page, limit: PAGE_SIZE });
- if (cancelled) return;
- setTasks(response.data.items);
- setPagination(response.data.pagination);
- } catch (error: unknown) {
- if (cancelled) return;
- showError(formatApiErrorMessage(error, tRef.current('errorLoadList')));
- } finally {
- if (!cancelled) setLoading(false);
- }
- })();
-
- return () => {
- cancelled = true;
- };
- }, [canView, page, showError, setError]);
+ const timeout = setTimeout(() => void loadTasks(), search ? 300 : 0);
+ return () => clearTimeout(timeout);
+ }, [canView, loadTasks, search]);
async function handleStatusUpdate(taskId: string, status: LabTaskStatus) {
if (!canEdit) return;
-
setUpdatingTaskId(taskId);
setError('');
try {
await tasksApi.updateStatus(taskId, status);
- const response = await tasksApi.list({ page, limit: PAGE_SIZE });
- setTasks(response.data.items);
- setPagination(response.data.pagination);
+ await loadTasks();
} catch (error: unknown) {
showError(formatApiErrorMessage(error, t('errorUpdateTask')));
} finally {
@@ -123,9 +159,7 @@ export default function TasksPage() {
}).format(new Date(value));
}
- function sortDateForTask(task: LabTaskListItem) {
- return task.assignedAt ?? task.createdAt;
- }
+ const filterSelectClass = `${FORM_SELECT_CLASS} w-full rounded-md px-2 py-1.5 text-sm`;
if (!isAuthReady) {
return {t('loading')}
;
@@ -144,89 +178,229 @@ export default function TasksPage() {
+
+
{loading && tasks.length === 0 ? (
{t('loading')}
) : tasks.length === 0 ? (
-
- {isOwner ? t('emptyListOwner') : t('emptyList')}
-
+ {t('emptyList')}
) : (
- {tasks.map((task) => {
- const statusEditable =
- canEdit && (isOwner || task.assigneeUserId === user?.id);
+ {tasks.map((task, index) => {
+ const commentsOpen = expandedCommentsCaseId === task.labCaseId;
return (
-
-
-
- {task.stepOrder}. {task.stepLabel}
-
-
- {t('fromClinic', { name: task.clinic.name })} ·{' '}
- {formatPatientName(task.patient)} · {t('toothLabel', { tooth: task.tooth })}
- {task.prosthesisTypeLabel ? ` · ${task.prosthesisTypeLabel}` : ''}
-
-
- {t('taskDate', { date: formatTaskDate(sortDateForTask(task)) })}
- {isOwner && (
- <>
- ·
-
- {task.assignee
- ? t('assignedTo', { name: task.assignee.name })
- : t('unassigned')}
+
+
+
+
+
+ {task.stepOrder}. {task.stepLabel}
+
+ {task.isImportant ? (
+
+ {t('importantBadge')}
- >
+ ) : null}
+
+ {task.prosthesisTypeLabel}
+
+
+
+ {t('fromClinic', { name: task.clinic.name })} ·{' '}
+ {formatPatientName(task.patient)} ·{' '}
+ {t('teethLabel', { teeth: formatToothList(task.teeth) })}
+
+
+ {t('taskDate', { date: formatTaskDate(task.createdAt) })}
+ {task.lastStatusChangedBy ? (
+ <>
+ ·
+
+ {t('lastUpdatedBy', { name: task.lastStatusChangedBy.name })}
+
+ >
+ ) : null}
+
+
+
+
+ {canEdit ? (
+
+ void handleStatusUpdate(task.id, e.target.value as LabTaskStatus)
+ }
+ className={`${FORM_SELECT_CLASS} w-full max-w-[132px]`}
+ >
+ {statusOptions.map((opt) => (
+
+ {opt.label}
+
+ ))}
+
+ ) : (
+
+ {statusOptions.find((opt) => opt.value === task.status)?.label ??
+ task.status}
+
)}
-
+
+
+
+ {canEdit ? (
+
+ setExpandedCommentsCaseId(commentsOpen ? null : task.labCaseId)
+ }
+ className={`p-1.5 rounded border ${
+ commentsOpen
+ ? 'border-primary bg-primary/10 text-primary'
+ : 'border-border text-text-muted hover:border-primary/40'
+ }`}
+ title={t('commentsButton')}
+ >
+
+
+ ) : null}
+
+
-
- {statusEditable ? (
-
- void handleStatusUpdate(task.id, e.target.value as LabTaskStatus)
- }
- className={`${FORM_SELECT_CLASS} w-full max-w-[132px]`}
- >
- {statusOptions.map((opt) => (
-
- {opt.label}
-
- ))}
-
- ) : (
-
- {statusOptions.find((opt) => opt.value === task.status)?.label ??
- task.status}
-
- )}
-
-
-
-
- {t('priorityLabel', { n: task.priority })}
-
-
-
+ {commentsOpen && canEdit ? (
+
+ {
+ const r = await tasksApi.listComments(task.labCaseId);
+ return r.data;
+ }}
+ onPost={async (body, visibleToClinic) => {
+ const r = await tasksApi.addComment(task.labCaseId, {
+ body,
+ visibleToClinic,
+ });
+ return r.data;
+ }}
+ onToggleVisibility={async (commentId, visible) => {
+ const r = await tasksApi.setCommentVisibility(commentId, visible);
+ return r.data;
+ }}
+ onError={showError}
+ />
+
+ ) : null}
);
})}
diff --git a/frontend/src/components/ui/lab/LabCaseCommentsPanel.tsx b/frontend/src/components/ui/lab/LabCaseCommentsPanel.tsx
new file mode 100644
index 0000000..7e7bccc
--- /dev/null
+++ b/frontend/src/components/ui/lab/LabCaseCommentsPanel.tsx
@@ -0,0 +1,164 @@
+'use client';
+
+import { useCallback, useEffect, useState } from 'react';
+import { useTranslations } from 'next-intl';
+import { Eye, EyeOff } from 'lucide-react';
+import { formatApiErrorMessage } from '@/components/shared/formatApiError';
+import { Button } from '@/components/ui/shared/Button';
+import type { LabCaseComment } from '@/types/cases';
+
+interface LabCaseCommentsPanelProps {
+ caseId: string;
+ canPost: boolean;
+ canToggleVisibility: boolean;
+ loadComments: () => Promise;
+ onPost: (body: string, visibleToClinic?: boolean) => Promise;
+ onToggleVisibility?: (commentId: string, visible: boolean) => Promise;
+ onError?: (message: string) => void;
+}
+
+export function LabCaseCommentsPanel({
+ caseId,
+ canPost,
+ canToggleVisibility,
+ loadComments,
+ onPost,
+ onToggleVisibility,
+ onError,
+}: LabCaseCommentsPanelProps) {
+ const t = useTranslations('caseComments');
+ const [comments, setComments] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [posting, setPosting] = useState(false);
+ const [body, setBody] = useState('');
+ const [visibleToClinic, setVisibleToClinic] = useState(false);
+
+ const refresh = useCallback(async () => {
+ setLoading(true);
+ try {
+ const items = await loadComments();
+ setComments(items);
+ } catch (error: unknown) {
+ onError?.(formatApiErrorMessage(error, t('errorLoad')));
+ } finally {
+ setLoading(false);
+ }
+ }, [loadComments, onError, t]);
+
+ useEffect(() => {
+ void refresh();
+ }, [caseId, refresh]);
+
+ async function handlePost() {
+ const trimmed = body.trim();
+ if (!trimmed || !canPost) return;
+ setPosting(true);
+ try {
+ const created = await onPost(trimmed, visibleToClinic);
+ setComments((prev) => [...prev, created]);
+ setBody('');
+ setVisibleToClinic(false);
+ } catch (error: unknown) {
+ onError?.(formatApiErrorMessage(error, t('errorPost')));
+ } finally {
+ setPosting(false);
+ }
+ }
+
+ async function handleToggle(comment: LabCaseComment) {
+ if (!onToggleVisibility || !canToggleVisibility) return;
+ try {
+ const updated = await onToggleVisibility(comment.id, !comment.visibleToClinic);
+ setComments((prev) => prev.map((c) => (c.id === updated.id ? updated : c)));
+ } catch (error: unknown) {
+ onError?.(formatApiErrorMessage(error, t('errorToggle')));
+ }
+ }
+
+ return (
+
+
{t('title')}
+
+ {loading ? (
+
…
+ ) : comments.length === 0 ? (
+
{t('empty')}
+ ) : (
+
+ {comments.map((comment) => (
+
+
+
+
+
+ {comment.authorSide === 'LAB' ? t('labAuthor') : t('clinicAuthor')}
+ {comment.authorName ? ` · ${comment.authorName}` : ''}
+
+ {comment.visibleToClinic ? (
+ {t('clinicCanSee')}
+ ) : (
+ {t('hiddenFromClinic')}
+ )}
+
+
{comment.body}
+
+ {canToggleVisibility && comment.canToggleVisibility && onToggleVisibility ? (
+
void handleToggle(comment)}
+ className="shrink-0 p-1 rounded hover:bg-border text-text-muted"
+ title={
+ comment.visibleToClinic ? t('makeHidden') : t('makeVisible')
+ }
+ aria-label={
+ comment.visibleToClinic ? t('makeHidden') : t('makeVisible')
+ }
+ >
+ {comment.visibleToClinic ? (
+
+ ) : (
+
+ )}
+
+ ) : null}
+
+
+ ))}
+
+ )}
+
+ {canPost ? (
+
+
+ ) : null}
+
+ );
+}
diff --git a/frontend/src/components/ui/organizations/ConnectionCaseHistoryContent.tsx b/frontend/src/components/ui/organizations/ConnectionCaseHistoryContent.tsx
index a148755..1db405e 100644
--- a/frontend/src/components/ui/organizations/ConnectionCaseHistoryContent.tsx
+++ b/frontend/src/components/ui/organizations/ConnectionCaseHistoryContent.tsx
@@ -12,6 +12,11 @@ import { Badge, type BadgeVariant } from '@/components/ui/shared/Badge';
import { Button } from '@/components/ui/shared/Button';
import { SearchBar } from '@/components/ui/shared/SearchBar';
import { ToastStack } from '@/components/ui/shared/Toast';
+import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel';
+import {
+ formatToothList,
+ prosthesisTypeBadgeStyle,
+} from '@/components/ui/treatment/prosthesisTypeDisplay';
import type { CounterpartItemDto } from '@/lib/api/organization';
import type { LabCaseDetail, LabCaseListItem, LabTaskStatus } from '@/types/cases';
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
@@ -109,7 +114,6 @@ export function ConnectionCaseHistoryContent({
const statusOptions: { value: LabTaskStatus; label: string }[] = useMemo(
() => [
- { value: 'PENDING', label: tCases('statusPending') },
{ value: 'IN_PROGRESS', label: tCases('statusInProgress') },
{ value: 'COMPLETED', label: tCases('statusCompleted') },
],
@@ -363,17 +367,24 @@ export function ConnectionCaseHistoryContent({
{selectedCase.tasksByTooth.length === 0 ? (
{tCases('noTasks')}
) : (
- selectedCase.tasksByTooth.map((group) => (
+ selectedCase.tasksByTooth.map((group, groupIndex) => (
-
- {tCases('toothGroupTitle', {
- tooth: group.tooth,
- prosthesis: group.prosthesisTypeLabel,
- type: treatmentLabel(group.treatmentType),
- })}
+
+
+ {group.prosthesisTypeLabel}
+
+
+ {tCases('toothGroupTitle', {
+ teeth: formatToothList(group.teeth),
+ prosthesis: group.prosthesisTypeLabel,
+ })}
+
{group.tasks.map((task) => (
@@ -388,6 +399,11 @@ export function ConnectionCaseHistoryContent({
{statusOptions.find((opt) => opt.value === task.status)?.label ??
task.status}
+ {task.lastStatusChangedBy ? (
+
+ {tCases('lastUpdatedBy', { name: task.lastStatusChangedBy.name })}
+
+ ) : null}
))}
@@ -395,6 +411,30 @@ export function ConnectionCaseHistoryContent({
))
)}
+
+ {isClinic && selectedCaseId ? (
+
{
+ const r = await organizationApi.listConnectionCaseComments(
+ connection.id,
+ selectedCaseId,
+ );
+ return r.data;
+ }}
+ onPost={async (body) => {
+ const r = await organizationApi.addConnectionCaseComment(
+ connection.id,
+ selectedCaseId,
+ body,
+ );
+ return r.data;
+ }}
+ onError={showError}
+ />
+ ) : null}
)}
diff --git a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx
index d4ee63f..a4947fd 100644
--- a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx
+++ b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx
@@ -69,6 +69,27 @@ function labCaseDraftsToPast(
}));
}
+function enrichDetailsWithLabSendState(
+ details: TreatmentDetailDraft[],
+ labCaseDrafts: LabCaseDraft[],
+): TreatmentDetailDraft[] {
+ return details.map((detail) => {
+ const sentLabCase = labCaseDrafts.find(
+ (lc) => lc.sentAt && lc.detailClientIds.includes(detail.clientId),
+ );
+ if (!sentLabCase) return detail;
+ return {
+ ...detail,
+ labCaseId: sentLabCase.id ?? detail.labCaseId,
+ sentAt: sentLabCase.sentAt ?? detail.sentAt,
+ sends: sentLabCase.sends ?? detail.sends,
+ sendToOrganizationIds: sentLabCase.destinationOrganizationId
+ ? [sentLabCase.destinationOrganizationId]
+ : detail.sendToOrganizationIds,
+ };
+ });
+}
+
function buildWorkspaceSnapshot(
appointment: TreatmentAppointment,
details: TreatmentDetailDraft[],
@@ -76,8 +97,9 @@ function buildWorkspaceSnapshot(
title: string,
id?: string,
): PastTreatment {
+ const detailsForPreview = enrichDetailsWithLabSendState(details, labCaseDrafts);
return {
- ...detailsToPreviewTreatment(details, {
+ ...detailsToPreviewTreatment(detailsForPreview, {
id: id ?? `preview-${appointment.id}`,
title,
patientId: appointment.patientId,
@@ -844,6 +866,22 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
const response = await treatmentsApi.sendLabCase(refreshedLabCase.id);
+ const sentDetailClientIds = new Set(labCase.detailClientIds);
+ setDetails((prev) =>
+ prev.map((detail) => {
+ if (!sentDetailClientIds.has(detail.clientId)) return detail;
+ return {
+ ...detail,
+ labCaseId: response.data.id,
+ sentAt: response.data.sentAt,
+ sends: response.data.sends,
+ sendToOrganizationIds: response.data.destinationOrganizationId
+ ? [response.data.destinationOrganizationId]
+ : detail.sendToOrganizationIds,
+ };
+ }),
+ );
+
setLabCaseDrafts((prev) =>
prev.map((lc) =>
lc.clientId === labCase.clientId
diff --git a/frontend/src/components/ui/treatment/prosthesisTypeDisplay.ts b/frontend/src/components/ui/treatment/prosthesisTypeDisplay.ts
new file mode 100644
index 0000000..b60f09f
--- /dev/null
+++ b/frontend/src/components/ui/treatment/prosthesisTypeDisplay.ts
@@ -0,0 +1,72 @@
+import type { CSSProperties } from 'react';
+
+/**
+ * Prosthesis-type colors for lab-facing surfaces (Tasks list, Cases detail group
+ * headers / badges). Grouped by material family, loosely inspired by exocad's
+ * material color conventions:
+ * - Zirconia family → pale green/cream
+ * - PFM / full metal → steel gray
+ * - Glass-ceramic / IPS (press & CAD) → warm amber
+ * - Resin / PMMA / PEEK / temporary → mint/teal
+ * - Abutments / screw-retained → slate blue
+ * - Smile design / mockup → lavender/pink
+ *
+ * Clinic-facing dispatch flows intentionally do NOT use these colors.
+ */
+const PROSTHESIS_TYPE_COLORS: Record = {
+ // Zirconia family
+ monolithic_zirconia: '#d9f2e6',
+ pfz_crown: '#c7ede0',
+ veneer_zirconia: '#b8e6d5',
+ zirconia_abutment: '#a7dcc8',
+ zirconia_overlay: '#cdeede',
+ // PFM / metal
+ pfm_crown: '#cbd5e1',
+ full_metal_crown: '#b8c2cf',
+ // Glass-ceramic / IPS
+ glass_ceramic_crown: '#fde3a7',
+ veneer_ips_press: '#fcd88f',
+ veneer_ips_cad: '#f9cf9c',
+ ips_overlay: '#fbe0b0',
+ // Resin / PMMA / PEEK / temporary
+ temporary_resin_crown: '#bfeaf0',
+ pmma: '#a9e2ea',
+ peek_crown: '#b7e4dd',
+ soft_structure: '#d4eef0',
+ // Abutments / screw-retained
+ customized_abutment: '#aec6e8',
+ prefabricated_abutment: '#9db8e0',
+ ti_base_abutment: '#c0d0ec',
+ multi_unit_abutment: '#b4c4e6',
+ screw_retained: '#a8bce2',
+ // Design / mockup
+ smile_design: '#e9d5ff',
+ mockup: '#f5d0fe',
+};
+
+const FALLBACK_COLORS = ['#ddd6fe', '#fed7aa', '#fecaca', '#bae6fd', '#d9f99d', '#fbcfe8'];
+
+/** Dark ink that stays readable on every pastel in the palette. */
+const BADGE_INK = '#14253d';
+
+export function prosthesisTypeColor(code: string, index = 0): string {
+ return PROSTHESIS_TYPE_COLORS[code] ?? FALLBACK_COLORS[index % FALLBACK_COLORS.length];
+}
+
+/** Filled swatch (small indicator dots). */
+export function prosthesisTypeSwatchStyle(code: string, index = 0): CSSProperties {
+ return { backgroundColor: prosthesisTypeColor(code, index), borderColor: 'rgba(0, 0, 0, 0.18)' };
+}
+
+/** Pastel pill / banner fill with readable dark text (group headers, badges). */
+export function prosthesisTypeBadgeStyle(code: string, index = 0): CSSProperties {
+ return {
+ backgroundColor: prosthesisTypeColor(code, index),
+ borderColor: 'rgba(0, 0, 0, 0.16)',
+ color: BADGE_INK,
+ };
+}
+
+export function formatToothList(teeth: string[]): string {
+ return teeth.join(', ');
+}
diff --git a/frontend/src/lib/api/cases.ts b/frontend/src/lib/api/cases.ts
index 2290edf..a51a076 100644
--- a/frontend/src/lib/api/cases.ts
+++ b/frontend/src/lib/api/cases.ts
@@ -1,6 +1,5 @@
import { apiClient } from './client';
import type {
- AssignableMember,
CasesFilterOptions,
LabCaseDetail,
LabCaseTask,
@@ -21,22 +20,17 @@ export const casesApi = {
return response.data;
},
- listAssignableMembers: async (): Promise<{ success: boolean; data: AssignableMember[] }> => {
- const response = await apiClient.get('/cases/assignable-members');
- return response.data;
- },
-
listFilterOptions: async (): Promise<{ success: boolean; data: CasesFilterOptions }> => {
const response = await apiClient.get('/cases/filter-options');
return response.data;
},
- updateTask: async (
+ setTaskImportant: async (
caseId: string,
taskId: string,
- payload: { assigneeUserId?: string | null; priority?: number },
+ isImportant: boolean,
): Promise<{ success: boolean; data: LabCaseTask }> => {
- const response = await apiClient.patch(`/cases/${caseId}/tasks/${taskId}`, payload);
+ const response = await apiClient.patch(`/cases/${caseId}/tasks/${taskId}`, { isImportant });
return response.data;
},
};
diff --git a/frontend/src/lib/api/organization.ts b/frontend/src/lib/api/organization.ts
index b9a4f74..20a7195 100644
--- a/frontend/src/lib/api/organization.ts
+++ b/frontend/src/lib/api/organization.ts
@@ -1,5 +1,6 @@
import { apiClient } from './client';
import type {
+ LabCaseComment,
LabCaseDetail,
ListLabCasesParams,
PaginatedLabCases,
@@ -161,4 +162,26 @@ export const organizationApi = {
);
return response.data;
},
+
+ listConnectionCaseComments: async (
+ connectionId: string,
+ caseId: string,
+ ): Promise<{ success: boolean; data: LabCaseComment[] }> => {
+ const response = await apiClient.get(
+ `/organizations/connections/${connectionId}/cases/${caseId}/comments`,
+ );
+ return response.data;
+ },
+
+ addConnectionCaseComment: async (
+ connectionId: string,
+ caseId: string,
+ body: string,
+ ): Promise<{ success: boolean; data: LabCaseComment }> => {
+ const response = await apiClient.post(
+ `/organizations/connections/${connectionId}/cases/${caseId}/comments`,
+ { body },
+ );
+ return response.data;
+ },
};
diff --git a/frontend/src/lib/api/tasks.ts b/frontend/src/lib/api/tasks.ts
index d06875a..fa3ecc2 100644
--- a/frontend/src/lib/api/tasks.ts
+++ b/frontend/src/lib/api/tasks.ts
@@ -1,11 +1,16 @@
import { apiClient } from './client';
-import type { LabTaskListItem, LabTaskStatus, PaginatedLabTasks } from '@/types/cases';
+import type {
+ LabCaseComment,
+ LabTaskListItem,
+ LabTaskStatus,
+ ListLabTasksParams,
+ PaginatedLabTasks,
+} from '@/types/cases';
export const tasksApi = {
- list: async (params: { page?: number; limit?: number } = {}): Promise<{
- success: boolean;
- data: PaginatedLabTasks;
- }> => {
+ list: async (
+ params: ListLabTasksParams = {},
+ ): Promise<{ success: boolean; data: PaginatedLabTasks }> => {
const response = await apiClient.get('/tasks', { params });
return response.data;
},
@@ -17,4 +22,29 @@ export const tasksApi = {
const response = await apiClient.patch(`/tasks/${taskId}`, { status });
return response.data;
},
+
+ listComments: async (
+ caseId: string,
+ ): Promise<{ success: boolean; data: LabCaseComment[] }> => {
+ const response = await apiClient.get(`/case-comments/${caseId}`);
+ return response.data;
+ },
+
+ addComment: async (
+ caseId: string,
+ payload: { body: string; visibleToClinic?: boolean },
+ ): Promise<{ success: boolean; data: LabCaseComment }> => {
+ const response = await apiClient.post(`/case-comments/${caseId}`, payload);
+ return response.data;
+ },
+
+ setCommentVisibility: async (
+ commentId: string,
+ visibleToClinic: boolean,
+ ): Promise<{ success: boolean; data: LabCaseComment }> => {
+ const response = await apiClient.patch(`/case-comments/item/${commentId}/visibility`, {
+ visibleToClinic,
+ });
+ return response.data;
+ },
};
diff --git a/frontend/src/types/cases.ts b/frontend/src/types/cases.ts
index ba18314..3fd34a1 100644
--- a/frontend/src/types/cases.ts
+++ b/frontend/src/types/cases.ts
@@ -1,4 +1,4 @@
-export type LabTaskStatus = 'PENDING' | 'IN_PROGRESS' | 'COMPLETED';
+export type LabTaskStatus = 'IN_PROGRESS' | 'COMPLETED';
export interface LabCaseListItem {
id: string;
@@ -14,9 +14,23 @@ export interface LabCaseListItem {
taskProgress: { completed: number; total: number };
}
+export interface LabTaskUser {
+ id: string;
+ name: string;
+}
+
+export interface LabTaskTimelineEvent {
+ id: string;
+ fromStatus: LabTaskStatus | null;
+ toStatus: LabTaskStatus;
+ changedAt: string;
+ changedBy: LabTaskUser | null;
+}
+
export interface LabCaseTask {
id: string;
- tooth: string;
+ treatmentDetailId: string;
+ teeth: string[];
treatmentType: string;
prosthesisTypeCode: string;
prosthesisTypeLabel: string;
@@ -24,21 +38,33 @@ export interface LabCaseTask {
stepOrder: number;
stepLabel: string;
status: LabTaskStatus;
- priority: number;
- assignedAt: string | null;
+ isImportant: boolean;
createdAt: string;
- assigneeUserId: string | null;
- assignee: { id: string; name: string; email: string } | null;
+ lastStatusChangedAt: string | null;
+ lastStatusChangedBy: LabTaskUser | null;
+ timeline: LabTaskTimelineEvent[];
}
-export interface LabCaseTasksByTooth {
- tooth: string;
+export interface LabCaseTaskGroup {
+ treatmentDetailId: string;
+ teeth: string[];
treatmentType: string;
prosthesisTypeCode: string;
prosthesisTypeLabel: string;
tasks: LabCaseTask[];
}
+export interface LabCaseComment {
+ id: string;
+ body: string;
+ authorSide: 'LAB' | 'CLINIC';
+ authorName: string | null;
+ authorOrganizationName: string | null;
+ visibleToClinic: boolean;
+ createdAt: string;
+ canToggleVisibility: boolean;
+}
+
export interface LabCaseDetail {
id: string;
sentAt: string | null;
@@ -64,17 +90,10 @@ export interface LabCaseDetail {
sentAt: string;
}>;
tasks: LabCaseTask[];
- tasksByTooth: LabCaseTasksByTooth[];
+ tasksByTooth: LabCaseTaskGroup[];
taskProgress: { completed: number; total: number };
}
-export interface AssignableMember {
- userId: string;
- name: string;
- email: string;
- isOwner: boolean;
-}
-
export interface ListLabCasesParams {
q?: string;
page?: number;
@@ -100,21 +119,38 @@ export interface PaginatedLabCases {
};
}
+export type TaskSortField = 'date' | 'status' | 'clinic' | 'patient' | 'important';
+
+export interface ListLabTasksParams {
+ q?: string;
+ clinicOrganizationId?: string;
+ status?: LabTaskStatus;
+ completed?: boolean;
+ important?: boolean;
+ sentFrom?: string;
+ sentTo?: string;
+ sortBy?: TaskSortField;
+ sortDir?: 'asc' | 'desc';
+ page?: number;
+ limit?: number;
+}
+
export interface LabTaskListItem {
id: string;
labCaseId: string;
- tooth: string;
+ treatmentDetailId: string;
+ teeth: string[];
treatmentType: string;
prosthesisTypeCode: string;
prosthesisTypeLabel: string;
+ workflowStepCode: string;
stepOrder: number;
stepLabel: string;
status: LabTaskStatus;
- priority: number;
- assignedAt: string | null;
+ isImportant: boolean;
+ lastStatusChangedAt: string | null;
+ lastStatusChangedBy: LabTaskUser | null;
createdAt: string;
- assigneeUserId: string | null;
- assignee: { id: string; name: string; email: string } | null;
clinic: { id: string; name: string };
patient: { id: string; firstName: string; lastName: string };
}
--
2.53.0.windows.1
From b2d40b3e9732331e9a22b7eaa6ca301d2175eed2 Mon Sep 17 00:00:00 2001
From: Admin
Date: Tue, 7 Jul 2026 17:14:31 +0330
Subject: [PATCH 15/17] improvement: users can now comment on a case and it's
details and have an option to make it visible for clinics too.
---
.../migration.sql | 23 +++
backend/prisma/schema.prisma | 1 -
backend/src/modules/cases/cases.service.ts | 1 -
.../lab-case-comments.service.ts | 93 ++++++++-
.../modules/treatments/dto/treatment.dto.ts | 5 -
.../treatments/treatments.controller.ts | 37 +++-
.../modules/treatments/treatments.module.ts | 3 +-
.../modules/treatments/treatments.service.ts | 5 -
frontend/messages/en.json | 6 +-
frontend/messages/fa.json | 6 +-
frontend/messages/nl.json | 6 +-
.../app/[locale]/(dashboard)/cases/page.tsx | 80 +++++---
.../app/[locale]/(dashboard)/tasks/page.tsx | 85 ++++-----
.../ui/lab/LabCaseCommentsPanel.tsx | 12 +-
.../components/ui/lab/labTaskStatusDisplay.ts | 17 ++
.../ConnectionCaseHistoryContent.tsx | 100 +++++-----
.../ui/treatment/LabCasesDispatchPanel.tsx | 177 ++++++++----------
.../ui/treatment/TreatmentWorkspace.tsx | 57 ++++--
frontend/src/lib/api/treatments.ts | 16 ++
frontend/src/types/cases.ts | 2 +-
frontend/src/types/treatment.ts | 4 -
21 files changed, 467 insertions(+), 269 deletions(-)
create mode 100644 backend/prisma/migrations/20260707140000_unify_lab_case_comments/migration.sql
create mode 100644 frontend/src/components/ui/lab/labTaskStatusDisplay.ts
diff --git a/backend/prisma/migrations/20260707140000_unify_lab_case_comments/migration.sql b/backend/prisma/migrations/20260707140000_unify_lab_case_comments/migration.sql
new file mode 100644
index 0000000..52128f9
--- /dev/null
+++ b/backend/prisma/migrations/20260707140000_unify_lab_case_comments/migration.sql
@@ -0,0 +1,23 @@
+-- Migrate legacy single-string labComment into per-case comment rows, then drop the column.
+
+INSERT INTO "lab_case_comments" (
+ "id",
+ "labCaseId",
+ "authorSide",
+ "body",
+ "visibleToClinic",
+ "createdAt",
+ "updatedAt"
+)
+SELECT
+ gen_random_uuid()::text,
+ lc."id",
+ 'CLINIC'::"LabCaseCommentSide",
+ trim(lc."labComment"),
+ true,
+ COALESCE(lc."sentAt", NOW()),
+ NOW()
+FROM "lab_cases" lc
+WHERE lc."labComment" IS NOT NULL AND trim(lc."labComment") <> '';
+
+ALTER TABLE "lab_cases" DROP COLUMN "labComment";
diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma
index c317f39..4494397 100644
--- a/backend/prisma/schema.prisma
+++ b/backend/prisma/schema.prisma
@@ -193,7 +193,6 @@ model LabCase {
clientKey String?
sortOrder Int
destinationOrganizationId String?
- labComment String?
sentAt DateTime?
treatment Treatment @relation(fields: [treatmentId], references: [id], onDelete: Cascade)
diff --git a/backend/src/modules/cases/cases.service.ts b/backend/src/modules/cases/cases.service.ts
index f873ccb..5404942 100644
--- a/backend/src/modules/cases/cases.service.ts
+++ b/backend/src/modules/cases/cases.service.ts
@@ -447,7 +447,6 @@ export class CasesService {
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,
diff --git a/backend/src/modules/lab-case-comments/lab-case-comments.service.ts b/backend/src/modules/lab-case-comments/lab-case-comments.service.ts
index a1a04c9..e69a68c 100644
--- a/backend/src/modules/lab-case-comments/lab-case-comments.service.ts
+++ b/backend/src/modules/lab-case-comments/lab-case-comments.service.ts
@@ -74,11 +74,11 @@ export class LabCaseCommentsService {
return { success: true, data: this.mapComment(updated, LabCaseCommentSide.LAB) };
}
- // ---------- Clinic side (connection access is validated by caller) ----------
+ // ---------- Clinic side (connection history) ----------
async listForClinic(caseId: string, clinicOrganizationId: string) {
- await this.assertClinicOwnsCase(caseId, clinicOrganizationId);
- const comments = await this.fetchComments(caseId, { visibleOnly: true });
+ await this.assertClinicOwnsCase(caseId, clinicOrganizationId, { requireSent: true });
+ const comments = await this.fetchCommentsForClinicViewer(caseId);
return {
success: true,
data: comments.map((c) => this.mapComment(c, LabCaseCommentSide.CLINIC)),
@@ -91,7 +91,7 @@ export class LabCaseCommentsService {
actorUserId: string,
dto: CreateLabCaseCommentDto,
) {
- await this.assertClinicOwnsCase(caseId, clinicOrganizationId);
+ await this.assertClinicOwnsCase(caseId, clinicOrganizationId, { requireSent: true });
const created = await this.prisma.labCaseComment.create({
data: {
labCaseId: caseId,
@@ -99,7 +99,6 @@ export class LabCaseCommentsService {
authorOrganizationId: clinicOrganizationId,
authorSide: LabCaseCommentSide.CLINIC,
body: dto.body.trim(),
- // Clinic-authored comments are inherently visible to the clinic.
visibleToClinic: true,
},
include: commentInclude,
@@ -107,8 +106,60 @@ export class LabCaseCommentsService {
return { success: true, data: this.mapComment(created, LabCaseCommentSide.CLINIC) };
}
+ // ---------- Clinic side (treatment dispatch — unsent cases allowed) ----------
+
+ async listForClinicTreatmentCase(
+ caseId: string,
+ clinicOrganizationId: string,
+ actorUserId: string,
+ ) {
+ await this.assertClinicTreatmentAccess(caseId, clinicOrganizationId, actorUserId);
+ const comments = await this.fetchCommentsForClinicViewer(caseId);
+ return {
+ success: true,
+ data: comments.map((c) => this.mapComment(c, LabCaseCommentSide.CLINIC)),
+ };
+ }
+
+ async addForClinicTreatmentCase(
+ caseId: string,
+ clinicOrganizationId: string,
+ actorUserId: string,
+ dto: CreateLabCaseCommentDto,
+ ) {
+ await this.assertClinicTreatmentAccess(caseId, clinicOrganizationId, actorUserId);
+ const created = await this.prisma.labCaseComment.create({
+ data: {
+ labCaseId: caseId,
+ authorUserId: actorUserId,
+ authorOrganizationId: clinicOrganizationId,
+ authorSide: LabCaseCommentSide.CLINIC,
+ body: dto.body.trim(),
+ visibleToClinic: true,
+ },
+ include: commentInclude,
+ });
+ return { success: true, data: this.mapComment(created, LabCaseCommentSide.CLINIC) };
+ }
+
+ async countForCase(caseId: string) {
+ const count = await this.prisma.labCaseComment.count({ where: { labCaseId: caseId } });
+ return { success: true, data: { count } };
+ }
+
// ---------- Helpers ----------
+ private fetchCommentsForClinicViewer(caseId: string) {
+ return this.prisma.labCaseComment.findMany({
+ where: {
+ labCaseId: caseId,
+ OR: [{ visibleToClinic: true }, { authorSide: LabCaseCommentSide.CLINIC }],
+ },
+ include: commentInclude,
+ orderBy: { createdAt: 'asc' },
+ });
+ }
+
private fetchComments(caseId: string, opts?: { visibleOnly?: boolean }) {
return this.prisma.labCaseComment.findMany({
where: {
@@ -121,6 +172,7 @@ export class LabCaseCommentsService {
}
private mapComment(comment: CommentWithRelations, viewerSide: LabCaseCommentSide) {
+ const showVisibilityStatus = viewerSide === LabCaseCommentSide.LAB;
return {
id: comment.id,
body: comment.body,
@@ -129,10 +181,10 @@ export class LabCaseCommentsService {
authorOrganizationName: comment.authorOrganization?.name ?? null,
visibleToClinic: comment.visibleToClinic,
createdAt: comment.createdAt.toISOString(),
- // Only lab viewers can toggle visibility, and only on lab-authored comments.
canToggleVisibility:
viewerSide === LabCaseCommentSide.LAB &&
comment.authorSide === LabCaseCommentSide.LAB,
+ showVisibilityStatus,
};
}
@@ -167,11 +219,15 @@ export class LabCaseCommentsService {
}
}
- private async assertClinicOwnsCase(caseId: string, clinicOrganizationId: string) {
+ private async assertClinicOwnsCase(
+ caseId: string,
+ clinicOrganizationId: string,
+ opts?: { requireSent?: boolean },
+ ) {
const labCase = await this.prisma.labCase.findFirst({
where: {
id: caseId,
- sentAt: { not: null },
+ ...(opts?.requireSent ? { sentAt: { not: null } } : {}),
treatment: { organizationId: clinicOrganizationId },
},
select: { id: true },
@@ -180,4 +236,25 @@ export class LabCaseCommentsService {
throw new NotFoundException('Case not found');
}
}
+
+ private async assertClinicTreatmentAccess(
+ caseId: string,
+ clinicOrganizationId: string,
+ actorUserId: string,
+ ) {
+ await this.assertClinicOwnsCase(caseId, clinicOrganizationId);
+ const membership = await this.prisma.membership.findFirst({
+ where: { userId: actorUserId, organizationId: clinicOrganizationId, isActive: true },
+ include: { permissions: { include: { permission: true } } },
+ });
+ if (!membership) {
+ throw new ForbiddenException('You are not a member of this organization');
+ }
+ if (membership.isOwner) return;
+ const names = membership.permissions.map((p) => p.permission.name);
+ if (names.includes('TAB_TREATMENT_READ') || names.includes('TAB_TREATMENT_EDIT')) {
+ return;
+ }
+ throw new ForbiddenException('You do not have access to treatment cases');
+ }
}
diff --git a/backend/src/modules/treatments/dto/treatment.dto.ts b/backend/src/modules/treatments/dto/treatment.dto.ts
index 0b78f62..0e38e8d 100644
--- a/backend/src/modules/treatments/dto/treatment.dto.ts
+++ b/backend/src/modules/treatments/dto/treatment.dto.ts
@@ -71,11 +71,6 @@ export class SaveLabCaseDto {
@IsUUID()
destinationOrganizationId?: string;
- @IsOptional()
- @IsString()
- @MaxLength(5000)
- labComment?: string;
-
@IsArray()
@ArrayMinSize(1)
@IsUUID(undefined, { each: true })
diff --git a/backend/src/modules/treatments/treatments.controller.ts b/backend/src/modules/treatments/treatments.controller.ts
index da02dcb..59c7e54 100644
--- a/backend/src/modules/treatments/treatments.controller.ts
+++ b/backend/src/modules/treatments/treatments.controller.ts
@@ -23,6 +23,8 @@ import {
SaveTreatmentDraftDto,
SaveTreatmentLabCasesDto,
} from './dto/treatment.dto';
+import { CreateLabCaseCommentDto } from '../lab-case-comments/dto/lab-case-comment.dto';
+import { LabCaseCommentsService } from '../lab-case-comments/lab-case-comments.service';
import { TreatmentsService } from './treatments.service';
@ApiTags('treatments')
@@ -30,7 +32,10 @@ import { TreatmentsService } from './treatments.service';
@UseGuards(JwtAuthGuard, ClinicOrgGuard)
@Controller('treatments')
export class TreatmentsController {
- constructor(private readonly treatmentsService: TreatmentsService) {}
+ constructor(
+ private readonly treatmentsService: TreatmentsService,
+ private readonly commentsService: LabCaseCommentsService,
+ ) {}
@Get('linked-organizations')
@ApiOperation({ summary: 'List active linked counterpart organizations (TAB_TREATMENT_READ)' })
@@ -194,4 +199,34 @@ export class TreatmentsController {
req.user.language,
);
}
+
+ @Get('lab-cases/:labCaseId/comments')
+ @ApiOperation({ summary: 'List comments for a lab case during treatment dispatch' })
+ listLabCaseComments(
+ @Param('labCaseId') labCaseId: string,
+ @Req() req: { user: { id: string; organizationId?: string } },
+ ) {
+ const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user);
+ return this.commentsService.listForClinicTreatmentCase(
+ labCaseId,
+ organizationId,
+ req.user.id,
+ );
+ }
+
+ @Post('lab-cases/:labCaseId/comments')
+ @ApiOperation({ summary: 'Add a comment to a lab case during treatment dispatch' })
+ addLabCaseComment(
+ @Param('labCaseId') labCaseId: string,
+ @Body() dto: CreateLabCaseCommentDto,
+ @Req() req: { user: { id: string; organizationId?: string } },
+ ) {
+ const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user);
+ return this.commentsService.addForClinicTreatmentCase(
+ labCaseId,
+ organizationId,
+ req.user.id,
+ dto,
+ );
+ }
}
diff --git a/backend/src/modules/treatments/treatments.module.ts b/backend/src/modules/treatments/treatments.module.ts
index a587c2d..a819eea 100644
--- a/backend/src/modules/treatments/treatments.module.ts
+++ b/backend/src/modules/treatments/treatments.module.ts
@@ -2,11 +2,12 @@ import { Module } from '@nestjs/common';
import { PrismaService } from '../../../prisma/prisma.service';
import { ClinicOrgGuard } from '../../common/guards/clinic-org.guard';
import { ProsthesisCatalogModule } from '../prosthesis-catalog/prosthesis-catalog.module';
+import { LabCaseCommentsModule } from '../lab-case-comments/lab-case-comments.module';
import { TreatmentsController } from './treatments.controller';
import { TreatmentsService } from './treatments.service';
@Module({
- imports: [ProsthesisCatalogModule],
+ imports: [ProsthesisCatalogModule, LabCaseCommentsModule],
controllers: [TreatmentsController],
providers: [TreatmentsService, PrismaService, ClinicOrgGuard],
})
diff --git a/backend/src/modules/treatments/treatments.service.ts b/backend/src/modules/treatments/treatments.service.ts
index e2aed47..1964e1c 100644
--- a/backend/src/modules/treatments/treatments.service.ts
+++ b/backend/src/modules/treatments/treatments.service.ts
@@ -397,7 +397,6 @@ export class TreatmentsService {
clientKey: lc.clientId,
sortOrder: index,
destinationOrganizationId: lc.destinationOrganizationId ?? null,
- labComment: lc.labComment?.trim() || null,
},
})
: await tx.labCase.create({
@@ -406,7 +405,6 @@ export class TreatmentsService {
clientKey: lc.clientId,
sortOrder: index,
destinationOrganizationId: lc.destinationOrganizationId ?? null,
- labComment: lc.labComment?.trim() || null,
},
});
@@ -675,7 +673,6 @@ export class TreatmentsService {
clientKey: string | null;
sortOrder: number;
destinationOrganizationId: string | null;
- labComment: string | null;
sentAt: Date | null;
details: Array<{
treatmentDetailId: string;
@@ -754,7 +751,6 @@ export class TreatmentsService {
clientKey?: string | null;
sortOrder?: number;
destinationOrganizationId?: string | null;
- labComment?: string | null;
sentAt?: Date | null;
details?: Array<{
treatmentDetailId: string;
@@ -775,7 +771,6 @@ export class TreatmentsService {
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) => ({
diff --git a/frontend/messages/en.json b/frontend/messages/en.json
index c1eafb9..5afd92b 100644
--- a/frontend/messages/en.json
+++ b/frontend/messages/en.json
@@ -351,7 +351,8 @@
"filterSentTo": "Sent to",
"clearFilters": "Clear filters",
"patientMobile": "Mobile",
- "labComment": "Lab comment",
+ "showComments": "Comments",
+ "commentsCount": "Comments ({count})",
"prevPage": "Previous",
"nextPage": "Next",
"pageSummary": "Page {page} of {totalPages} ({total} cases)",
@@ -389,6 +390,7 @@
"sortClinic": "Clinic",
"sortPatient": "Patient",
"sortImportant": "Important",
+ "sortDirection": "Sort direction",
"clearFilters": "Clear filters",
"commentsButton": "Comments",
"errorLoadList": "Failed to load tasks.",
@@ -540,8 +542,6 @@
"prosthesisColTooth": "Tooth",
"prosthesisColDetail": "Detail",
"prosthesisColType": "Prosthesis type",
- "labComment": "Message for the lab",
- "labCommentPlaceholder": "Optional instructions for this shipment…",
"selectLab": "Destination lab",
"selectLabPlaceholder": "Choose a linked lab…",
"sendToLab": "Send to lab",
diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json
index 5c78203..e67a52f 100644
--- a/frontend/messages/fa.json
+++ b/frontend/messages/fa.json
@@ -351,7 +351,8 @@
"filterSentTo": "ارسال تا",
"clearFilters": "پاک کردن فیلترها",
"patientMobile": "موبایل",
- "labComment": "یادداشت آزمایشگاه",
+ "showComments": "نظرات",
+ "commentsCount": "نظرات ({count})",
"prevPage": "قبلی",
"nextPage": "بعدی",
"pageSummary": "صفحه {page} از {totalPages} ({total} پرونده)",
@@ -389,6 +390,7 @@
"sortClinic": "کلینیک",
"sortPatient": "بیمار",
"sortImportant": "مهم",
+ "sortDirection": "جهت مرتبسازی",
"clearFilters": "پاک کردن فیلترها",
"commentsButton": "نظرات",
"errorLoadList": "بارگذاری وظایف ناموفق بود.",
@@ -540,8 +542,6 @@
"prosthesisColTooth": "دندان",
"prosthesisColDetail": "جزئیات",
"prosthesisColType": "نوع پروتز",
- "labComment": "پیام برای لابراتوار",
- "labCommentPlaceholder": "دستورالعمل اختیاری برای این محموله…",
"selectLab": "لابراتوار مقصد",
"selectLabPlaceholder": "یک لابراتوار متصل انتخاب کنید…",
"sendToLab": "ارسال به لابراتوار",
diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json
index 6fa45d2..9aaf99e 100644
--- a/frontend/messages/nl.json
+++ b/frontend/messages/nl.json
@@ -351,7 +351,8 @@
"filterSentTo": "Verzonden tot",
"clearFilters": "Filters wissen",
"patientMobile": "Mobiel",
- "labComment": "Labnotitie",
+ "showComments": "Opmerkingen",
+ "commentsCount": "Opmerkingen ({count})",
"prevPage": "Vorige",
"nextPage": "Volgende",
"pageSummary": "Pagina {page} van {totalPages} ({total} dossiers)",
@@ -389,6 +390,7 @@
"sortClinic": "Kliniek",
"sortPatient": "Patiënt",
"sortImportant": "Belangrijk",
+ "sortDirection": "Sorteerrichting",
"clearFilters": "Filters wissen",
"commentsButton": "Opmerkingen",
"errorLoadList": "Taken laden mislukt.",
@@ -540,8 +542,6 @@
"prosthesisColTooth": "Tand",
"prosthesisColDetail": "Detail",
"prosthesisColType": "Prothesetype",
- "labComment": "Bericht voor het lab",
- "labCommentPlaceholder": "Optionele instructies voor deze zending…",
"selectLab": "Bestemmingslab",
"selectLabPlaceholder": "Kies een gekoppeld lab…",
"sendToLab": "Versturen naar lab",
diff --git a/frontend/src/app/[locale]/(dashboard)/cases/page.tsx b/frontend/src/app/[locale]/(dashboard)/cases/page.tsx
index c97b264..ad13b3f 100644
--- a/frontend/src/app/[locale]/(dashboard)/cases/page.tsx
+++ b/frontend/src/app/[locale]/(dashboard)/cases/page.tsx
@@ -3,13 +3,17 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { useTranslations } from 'next-intl';
+import { MessageSquare } from 'lucide-react';
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 { canEditCases } from '@/components/shared/permissions';
-import { Badge, type BadgeVariant } from '@/components/ui/shared/Badge';
+import { canEditCases, canEditTasks } from '@/components/shared/permissions';
+import { Badge } from '@/components/ui/shared/Badge';
+import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel';
+import { labTaskStatusVariant } from '@/components/ui/lab/labTaskStatusDisplay';
import { casesApi } from '@/lib/api/cases';
+import { tasksApi } from '@/lib/api/tasks';
import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
import { treatmentTypeLabelFromCatalog } from '@/components/ui/treatment/treatmentTypeDisplay';
import { Button } from '@/components/ui/shared/Button';
@@ -30,17 +34,6 @@ import {
const PAGE_SIZE = 20;
-function taskStatusVariant(status: LabTaskStatus): BadgeVariant {
- switch (status) {
- case 'COMPLETED':
- return 'success';
- case 'IN_PROGRESS':
- return 'default';
- default:
- return 'warning';
- }
-}
-
function formatPatientName(patient: { firstName: string; lastName: string }) {
return `${patient.firstName} ${patient.lastName}`.trim();
}
@@ -105,8 +98,10 @@ export default function CasesPage() {
const [loadingList, setLoadingList] = useState(false);
const [loadingDetail, setLoadingDetail] = useState(false);
const [updatingTaskId, setUpdatingTaskId] = useState(null);
+ const [commentCount, setCommentCount] = useState(0);
const canEdit = canEditCases(currentOrganization);
+ const canEditComments = canEditTasks(currentOrganization);
const locale = user?.language ?? 'en';
const treatmentLabel = useCallback(
@@ -200,12 +195,21 @@ export default function CasesPage() {
useEffect(() => {
if (selectedCaseId) {
void loadDetail(selectedCaseId);
+ void tasksApi
+ .listComments(selectedCaseId)
+ .then((r) => setCommentCount(r.data.length))
+ .catch(() => setCommentCount(0));
} else {
setSelectedCase(null);
+ setCommentCount(0);
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- reload when selection changes
}, [selectedCaseId]);
+ function scrollToComments() {
+ document.getElementById('case-comments')?.scrollIntoView({ behavior: 'smooth' });
+ }
+
function clearFilters() {
setSearch('');
setClinicId('');
@@ -408,9 +412,17 @@ export default function CasesPage() {
) : (
-
- {formatPatientName(selectedCase.patient)}
-
+
+
+ {formatPatientName(selectedCase.patient)}
+
+
+
+ {commentCount > 0
+ ? t('commentsCount', { count: commentCount })
+ : t('showComments')}
+
+
{t('patientMobile')}: {selectedCase.patient.mobile}
@@ -432,12 +444,6 @@ export default function CasesPage() {
total={selectedCase.taskProgress.total}
/>
- {selectedCase.labComment ? (
-
- {t('labComment')}: {' '}
- {selectedCase.labComment}
-
- ) : null}
{selectedCase.details.length > 0 && (
@@ -493,7 +499,7 @@ export default function CasesPage() {
{task.stepOrder}. {task.stepLabel}
-
+
{statusOptions.find((opt) => opt.value === task.status)?.label ??
task.status}
@@ -530,6 +536,34 @@ export default function CasesPage() {
))
)}
+
+ {selectedCaseId ? (
+
+ ) : null}
)}
diff --git a/frontend/src/app/[locale]/(dashboard)/tasks/page.tsx b/frontend/src/app/[locale]/(dashboard)/tasks/page.tsx
index 1a9a7e4..8b2837e 100644
--- a/frontend/src/app/[locale]/(dashboard)/tasks/page.tsx
+++ b/frontend/src/app/[locale]/(dashboard)/tasks/page.tsx
@@ -4,12 +4,15 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslations } from 'next-intl';
import { MessageSquare } from 'lucide-react';
import { ToastStack } from '@/components/ui/shared/Toast';
-import { Badge, type BadgeVariant } from '@/components/ui/shared/Badge';
+import { Badge } from '@/components/ui/shared/Badge';
import { Button } from '@/components/ui/shared/Button';
import { FORM_SELECT_CLASS } from '@/components/ui/shared/formSelectStyles';
import { SearchBar } from '@/components/ui/shared/SearchBar';
-import { TreatmentTypeBadge } from '@/components/ui/treatment/TreatmentTypeBadge';
import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel';
+import {
+ labTaskStatusSelectClass,
+ labTaskStatusVariant,
+} from '@/components/ui/lab/labTaskStatusDisplay';
import {
formatToothList,
prosthesisTypeBadgeStyle,
@@ -19,8 +22,6 @@ import { canEditTasks, canViewTasks } from '@/components/shared/permissions';
import { useAuth } from '@/lib/hooks/useAuth';
import { useToast } from '@/lib/hooks/useToast';
import { tasksApi } from '@/lib/api/tasks';
-import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
-import { treatmentTypeLabelFromCatalog } from '@/components/ui/treatment/treatmentTypeDisplay';
import type {
LabTaskListItem,
LabTaskStatus,
@@ -28,14 +29,9 @@ import type {
PaginatedLabTasks,
TaskSortField,
} from '@/types/cases';
-import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
const PAGE_SIZE = 50;
-function taskStatusVariant(status: LabTaskStatus): BadgeVariant {
- return status === 'COMPLETED' ? 'success' : 'default';
-}
-
function formatPatientName(patient: { firstName: string; lastName: string }) {
return `${patient.firstName} ${patient.lastName}`.trim();
}
@@ -55,7 +51,6 @@ export default function TasksPage() {
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [updatingTaskId, setUpdatingTaskId] = useState(null);
- const [treatmentCatalog, setTreatmentCatalog] = useState([]);
const [expandedCommentsCaseId, setExpandedCommentsCaseId] = useState(null);
const [search, setSearch] = useState('');
@@ -127,10 +122,6 @@ export default function TasksPage() {
}
}, [listParams, showError, setError]);
- useEffect(() => {
- void treatmentCatalogApi.list().then((r) => setTreatmentCatalog(r.data)).catch(() => {});
- }, []);
-
useEffect(() => {
if (!canView) return;
const timeout = setTimeout(() => void loadTasks(), search ? 300 : 0);
@@ -191,7 +182,7 @@ export default function TasksPage() {
}}
placeholder={t('searchPlaceholder')}
/>
-
+
{t('filterClinic')}
{t('sortBy')}
- setSortBy(e.target.value as TaskSortField)}
- className={filterSelectClass}
- >
- {t('sortDate')}
- {t('sortStatus')}
- {t('sortClinic')}
- {t('sortPatient')}
- {t('sortImportant')}
-
-
-
-
- setSortDir(e.target.value as 'asc' | 'desc')}
- className={filterSelectClass}
- >
- ↓
- ↑
-
+
+ setSortBy(e.target.value as TaskSortField)}
+ className={`${filterSelectClass} min-w-0 flex-1`}
+ >
+ {t('sortDate')}
+ {t('sortStatus')}
+ {t('sortClinic')}
+ {t('sortPatient')}
+ {t('sortImportant')}
+
+ setSortDir(e.target.value as 'asc' | 'desc')}
+ className={`${FORM_SELECT_CLASS} w-14 shrink-0 rounded-md px-2 py-1.5 text-sm`}
+ aria-label={t('sortDirection')}
+ >
+ ↓
+ ↑
+
+
@@ -303,12 +294,6 @@ export default function TasksPage() {
{t('importantBadge')}
) : null}
-
- {task.prosthesisTypeLabel}
-
{t('fromClinic', { name: task.clinic.name })} ·{' '}
@@ -336,7 +321,7 @@ export default function TasksPage() {
onChange={(e) =>
void handleStatusUpdate(task.id, e.target.value as LabTaskStatus)
}
- className={`${FORM_SELECT_CLASS} w-full max-w-[132px]`}
+ className={`${FORM_SELECT_CLASS} w-full max-w-[132px] ${labTaskStatusSelectClass(task.status)}`}
>
{statusOptions.map((opt) => (
@@ -345,7 +330,7 @@ export default function TasksPage() {
))}
) : (
-
+
{statusOptions.find((opt) => opt.value === task.status)?.label ??
task.status}
@@ -369,10 +354,12 @@ export default function TasksPage() {
) : null}
-
+
+ {task.prosthesisTypeLabel}
+
diff --git a/frontend/src/components/ui/lab/LabCaseCommentsPanel.tsx b/frontend/src/components/ui/lab/LabCaseCommentsPanel.tsx
index 7e7bccc..cf4c664 100644
--- a/frontend/src/components/ui/lab/LabCaseCommentsPanel.tsx
+++ b/frontend/src/components/ui/lab/LabCaseCommentsPanel.tsx
@@ -97,11 +97,13 @@ export function LabCaseCommentsPanel({
{comment.authorSide === 'LAB' ? t('labAuthor') : t('clinicAuthor')}
{comment.authorName ? ` · ${comment.authorName}` : ''}
- {comment.visibleToClinic ? (
-
{t('clinicCanSee')}
- ) : (
-
{t('hiddenFromClinic')}
- )}
+ {comment.showVisibilityStatus !== false ? (
+ comment.visibleToClinic ? (
+
{t('clinicCanSee')}
+ ) : (
+
{t('hiddenFromClinic')}
+ )
+ ) : null}
{comment.body}
diff --git a/frontend/src/components/ui/lab/labTaskStatusDisplay.ts b/frontend/src/components/ui/lab/labTaskStatusDisplay.ts
new file mode 100644
index 0000000..e0b9783
--- /dev/null
+++ b/frontend/src/components/ui/lab/labTaskStatusDisplay.ts
@@ -0,0 +1,17 @@
+import type { BadgeVariant } from '@/components/ui/shared/Badge';
+import type { LabTaskStatus } from '@/types/cases';
+
+export function labTaskStatusVariant(status: LabTaskStatus): BadgeVariant {
+ return status === 'COMPLETED' ? 'success' : 'default';
+}
+
+export function labTaskStatusSelectClass(status: LabTaskStatus): string {
+ switch (status) {
+ case 'COMPLETED':
+ return 'border-success/60 text-success';
+ case 'IN_PROGRESS':
+ return 'border-primary/60 text-primary';
+ default:
+ return '';
+ }
+}
diff --git a/frontend/src/components/ui/organizations/ConnectionCaseHistoryContent.tsx b/frontend/src/components/ui/organizations/ConnectionCaseHistoryContent.tsx
index 1db405e..438d4d5 100644
--- a/frontend/src/components/ui/organizations/ConnectionCaseHistoryContent.tsx
+++ b/frontend/src/components/ui/organizations/ConnectionCaseHistoryContent.tsx
@@ -2,17 +2,19 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslations } from 'next-intl';
+import { MessageSquare } from 'lucide-react';
import { formatApiErrorMessage } from '@/components/shared/formatApiError';
import { useAuth } from '@/lib/hooks/useAuth';
import { useToast } from '@/lib/hooks/useToast';
import { organizationApi } from '@/lib/api/organization';
import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
import { treatmentTypeLabelFromCatalog } from '@/components/ui/treatment/treatmentTypeDisplay';
-import { Badge, type BadgeVariant } from '@/components/ui/shared/Badge';
+import { Badge } from '@/components/ui/shared/Badge';
import { Button } from '@/components/ui/shared/Button';
import { SearchBar } from '@/components/ui/shared/SearchBar';
import { ToastStack } from '@/components/ui/shared/Toast';
import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel';
+import { labTaskStatusVariant } from '@/components/ui/lab/labTaskStatusDisplay';
import {
formatToothList,
prosthesisTypeBadgeStyle,
@@ -23,17 +25,6 @@ import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
const PAGE_SIZE = 20;
-function taskStatusVariant(status: LabTaskStatus): BadgeVariant {
- switch (status) {
- case 'COMPLETED':
- return 'success';
- case 'IN_PROGRESS':
- return 'default';
- default:
- return 'warning';
- }
-}
-
function formatPatientName(patient: { firstName: string; lastName: string }) {
return `${patient.firstName} ${patient.lastName}`.trim();
}
@@ -96,6 +87,7 @@ export function ConnectionCaseHistoryContent({
const [treatmentCatalog, setTreatmentCatalog] = useState
([]);
const [loadingList, setLoadingList] = useState(false);
const [loadingDetail, setLoadingDetail] = useState(false);
+ const [commentCount, setCommentCount] = useState(0);
const locale = user?.language ?? 'en';
const isClinic = currentOrganization?.type === 'CLINIC';
@@ -154,11 +146,21 @@ export function ConnectionCaseHistoryContent({
useEffect(() => {
if (!selectedCaseId) {
setSelectedCase(null);
+ setCommentCount(0);
return;
}
let cancelled = false;
+ void organizationApi
+ .listConnectionCaseComments(connection.id, selectedCaseId)
+ .then((r) => {
+ if (!cancelled) setCommentCount(r.data.length);
+ })
+ .catch(() => {
+ if (!cancelled) setCommentCount(0);
+ });
+
void (async () => {
setLoadingDetail(true);
setError('');
@@ -180,6 +182,10 @@ export function ConnectionCaseHistoryContent({
};
}, [selectedCaseId, connection.id, showError, setError]);
+ function scrollToComments() {
+ document.getElementById('case-comments')?.scrollIntoView({ behavior: 'smooth' });
+ }
+
return (
@@ -300,9 +306,19 @@ export function ConnectionCaseHistoryContent({
) : (
-
- {formatPatientName(selectedCase.patient)}
-
+
+
+ {formatPatientName(selectedCase.patient)}
+
+ {isClinic ? (
+
+
+ {commentCount > 0
+ ? tCases('commentsCount', { count: commentCount })
+ : tCases('showComments')}
+
+ ) : null}
+
{tCases('patientMobile')}: {selectedCase.patient.mobile}
@@ -330,12 +346,6 @@ export function ConnectionCaseHistoryContent({
total={selectedCase.taskProgress.total}
/>
- {selectedCase.labComment ? (
-
- {tCases('labComment')}: {' '}
- {selectedCase.labComment}
-
- ) : null}
{selectedCase.details.length > 0 && (
@@ -395,7 +405,7 @@ export function ConnectionCaseHistoryContent({
{task.stepOrder}. {task.stepLabel}
-
+
{statusOptions.find((opt) => opt.value === task.status)?.label ??
task.status}
@@ -413,27 +423,31 @@ export function ConnectionCaseHistoryContent({
{isClinic && selectedCaseId ? (
-
{
- const r = await organizationApi.listConnectionCaseComments(
- connection.id,
- selectedCaseId,
- );
- return r.data;
- }}
- onPost={async (body) => {
- const r = await organizationApi.addConnectionCaseComment(
- connection.id,
- selectedCaseId,
- body,
- );
- return r.data;
- }}
- onError={showError}
- />
+
) : null}
)}
diff --git a/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx b/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx
index 4b2965a..9ad7965 100644
--- a/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx
+++ b/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx
@@ -1,21 +1,23 @@
'use client';
-import { useEffect, useMemo, useState } from 'react';
+import { useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/shared/Button';
import { Checkbox } from '@/components/ui/shared/Checkbox';
import { Dropdown } from '@/components/ui/shared/Dropdown';
import { FORM_SELECT_CLASS } from '@/components/ui/shared/formSelectStyles';
import { SearchBar } from '@/components/ui/shared/SearchBar';
-import { formatCaseSentSummary } from '@/components/treatment/caseSendLabel';
import { CaseSentLabel } from '@/components/ui/treatment/CaseSentLabel';
+import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel';
import { treatmentTypeLabelFromCatalog } from '@/components/ui/treatment/treatmentTypeDisplay';
+import { treatmentsApi } from '@/lib/api/treatments';
import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog';
import type { ProsthesisCatalogEntry, TreatmentCatalogEntry } from '@/types/treatment-catalog';
import type { LabCaseDraft, LinkedOrganizationOption, TreatmentDetailDraft } from '@/types/treatment';
interface LabCasesDispatchPanelProps {
details: TreatmentDetailDraft[];
+ activeDetailId: string;
labCases: LabCaseDraft[];
labDependentCodes: Set;
treatmentCatalog: TreatmentCatalogEntry[];
@@ -32,6 +34,7 @@ interface LabCasesDispatchPanelProps {
sendBusyId: string | null;
onAddLabCase: () => void;
onSendLabCase: (labCase: LabCaseDraft) => void;
+ onCommentError?: (message: string) => void;
}
function sentDetailClientIds(labCases: LabCaseDraft[]): Set {
@@ -56,25 +59,6 @@ function detailInOtherDraftShipment(
);
}
-function unsentLabDetails(
- details: TreatmentDetailDraft[],
- labCases: LabCaseDraft[],
- labDependentCodes: Set,
-): TreatmentDetailDraft[] {
- const sent = sentDetailClientIds(labCases);
- return details.filter((d) => labDependentCodes.has(d.treatmentType) && !sent.has(d.clientId));
-}
-
-function detailsAvailableForNewShipment(
- details: TreatmentDetailDraft[],
- labCases: LabCaseDraft[],
- labDependentCodes: Set,
-): TreatmentDetailDraft[] {
- return unsentLabDetails(details, labCases, labDependentCodes).filter(
- (d) => !detailInOtherDraftShipment(d.clientId, labCases, ''),
- );
-}
-
function selectableDetailsForDraftShipment(
details: TreatmentDetailDraft[],
labCases: LabCaseDraft[],
@@ -93,9 +77,11 @@ function selectableDetailsForDraftShipment(
function prosthesisTeethRows(
labCase: LabCaseDraft,
details: TreatmentDetailDraft[],
+ scopeDetailClientId?: string,
): Array<{ detailClientId: string; tooth: string; detailNumber: number }> {
const rows: Array<{ detailClientId: string; tooth: string; detailNumber: number }> = [];
for (const clientId of labCase.detailClientIds) {
+ if (scopeDetailClientId && clientId !== scopeDetailClientId) continue;
const detail = details.find((d) => d.clientId === clientId);
if (!detail || detail.treatmentType !== 'prosthesis') continue;
const detailNumber = details.findIndex((d) => d.clientId === clientId) + 1;
@@ -106,8 +92,12 @@ function prosthesisTeethRows(
return rows;
}
-function isProsthesisMapComplete(labCase: LabCaseDraft, details: TreatmentDetailDraft[]): boolean {
- const rows = prosthesisTeethRows(labCase, details);
+function isProsthesisMapComplete(
+ labCase: LabCaseDraft,
+ details: TreatmentDetailDraft[],
+ scopeDetailClientId?: string,
+): boolean {
+ const rows = prosthesisTeethRows(labCase, details, scopeDetailClientId);
if (rows.length === 0) return true;
return rows.every((row) =>
labCase.toothProsthesis.some(
@@ -121,6 +111,7 @@ function isProsthesisMapComplete(labCase: LabCaseDraft, details: TreatmentDetail
export function LabCasesDispatchPanel({
details,
+ activeDetailId,
labCases,
labDependentCodes,
treatmentCatalog,
@@ -137,6 +128,7 @@ export function LabCasesDispatchPanel({
sendBusyId,
onAddLabCase,
onSendLabCase,
+ onCommentError,
}: LabCasesDispatchPanelProps) {
const t = useTranslations('treatment');
const [prosthesisOptions, setProsthesisOptions] = useState([]);
@@ -152,27 +144,35 @@ export function LabCasesDispatchPanel({
.map((id) => activeLinkedOrganizations.find((o) => o.id === id))
.filter(Boolean) as LinkedOrganizationOption[];
- const labEligibleDetails = useMemo(
- () => details.filter((d) => labDependentCodes.has(d.treatmentType)),
- [details, labDependentCodes],
+ const activeDetail = details.find((d) => d.clientId === activeDetailId) ?? null;
+ const isLabDependentDetail = Boolean(
+ activeDetail && labDependentCodes.has(activeDetail.treatmentType),
);
- const canAddLabShipment = useMemo(
- () => detailsAvailableForNewShipment(details, labCases, labDependentCodes).length > 0,
- [details, labCases, labDependentCodes],
- );
+ const labCaseForActiveDetail =
+ labCases.find((lc) => lc.detailClientIds.includes(activeDetailId)) ?? null;
const activeLabCase =
- labCases.find((lc) => lc.clientId === activeLabCaseId) ?? labCases[0] ?? null;
+ labCaseForActiveDetail ??
+ (activeLabCaseId ? labCases.find((lc) => lc.clientId === activeLabCaseId) : null);
+
+ const detailAlreadyInShipment = Boolean(labCaseForActiveDetail);
+ const canAddLabShipment =
+ !detailAlreadyInShipment &&
+ !detailInOtherDraftShipment(activeDetailId, labCases, '') &&
+ !sentDetailClientIds(labCases).has(activeDetailId);
+
const sent = Boolean(activeLabCase?.sentAt);
const activeLabOrgName = activeLabCase?.destinationOrganizationId
? orgs.find((o) => o.id === activeLabCase.destinationOrganizationId)?.name
: null;
- const prosthesisRows = activeLabCase ? prosthesisTeethRows(activeLabCase, details) : [];
+ const prosthesisRows = activeLabCase
+ ? prosthesisTeethRows(activeLabCase, details, activeDetailId)
+ : [];
const prosthesisComplete = activeLabCase
- ? isProsthesisMapComplete(activeLabCase, details)
+ ? isProsthesisMapComplete(activeLabCase, details, activeDetailId)
: true;
useEffect(() => {
@@ -196,6 +196,11 @@ export function LabCasesDispatchPanel({
};
}, [activeLabCase?.destinationOrganizationId]);
+ // Hide dispatch when the selected treatment detail is not lab-dependent.
+ if (!activeDetail || !isLabDependentDetail) {
+ return null;
+ }
+
function detailNumber(d: TreatmentDetailDraft) {
const idx = details.findIndex((row) => row.clientId === d.clientId);
return idx >= 0 ? idx + 1 : 0;
@@ -269,22 +274,15 @@ export function LabCasesDispatchPanel({
);
}
- if (labEligibleDetails.length === 0) {
- return (
-
-
{t('labDispatchTitle')}
-
{t('noLabDetails')}
-
- );
- }
-
const includedInActiveShipment = activeLabCase
- ? labEligibleDetails.filter((d) => activeLabCase.detailClientIds.includes(d.clientId))
+ ? [activeDetail]
: [];
const pickableForActiveDraft =
activeLabCase && !sent
- ? selectableDetailsForDraftShipment(details, labCases, labDependentCodes, activeLabCase)
+ ? selectableDetailsForDraftShipment(details, labCases, labDependentCodes, activeLabCase).filter(
+ (d) => d.clientId === activeDetailId,
+ )
: [];
return (
@@ -308,45 +306,10 @@ export function LabCasesDispatchPanel({
)}
- {labCases.length === 0 ? (
+ {!detailAlreadyInShipment ? (
{t('labDispatchEmpty')}
- ) : (
- <>
-
- {labCases.map((lc, idx) => {
- const sentSummary = formatCaseSentSummary(
- lc.sends,
- {
- organizationIds: lc.destinationOrganizationId ? [lc.destinationOrganizationId] : [],
- sentAt: lc.sentAt ?? null,
- orgs,
- },
- t,
- );
- return (
- onActiveLabCaseChange(lc.clientId)}
- className={`
- rounded-[var(--radius-md)] border px-3 py-1.5 text-sm transition-colors
- focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/45
- ${
- lc.clientId === activeLabCase?.clientId
- ? 'border-primary bg-primary-soft font-medium text-text-primary'
- : 'border-border/70 text-text-secondary hover:border-border hover:bg-background-card/50'
- }
- `}
- >
- {t('labShipmentLabel', { n: idx + 1 })}
- {sentSummary ? ` · ${sentSummary}` : ''}
-
- );
- })}
-
-
- {activeLabCase && (
-
+ ) : activeLabCase ? (
+
{sent ? (
<>
@@ -369,13 +332,20 @@ export function LabCasesDispatchPanel({
)}
- {activeLabCase.labComment.trim() ? (
-
-
{t('labComment')}
-
- {activeLabCase.labComment}
-
-
+ {activeLabCase.id ? (
+
{
+ const r = await treatmentsApi.listLabCaseComments(activeLabCase.id!);
+ return r.data;
+ }}
+ onPost={async () => {
+ throw new Error('Read-only');
+ }}
+ onError={onCommentError}
+ />
) : null}
{activeLabOrgName ? (
@@ -423,17 +393,22 @@ export function LabCasesDispatchPanel({
)}
-
- {t('labComment')}
-
+ ) : null}
{t('selectLab')}
@@ -581,9 +556,7 @@ export function LabCasesDispatchPanel({
>
)}
- )}
- >
- )}
+ ) : null}
);
}
diff --git a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx
index a4947fd..40ff42e 100644
--- a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx
+++ b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx
@@ -51,7 +51,6 @@ function labCaseDraftsToPast(
id: lc.id ?? lc.clientId,
clientId: lc.clientId,
destinationOrganizationId: lc.destinationOrganizationId,
- labComment: lc.labComment || null,
sentAt: lc.sentAt ?? null,
treatmentDetailIds: lc.detailClientIds
.map((cid) => details.find((d) => d.clientId === cid)?.id)
@@ -132,7 +131,6 @@ function newLabCaseDraft(): LabCaseDraft {
? crypto.randomUUID()
: `lab-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
destinationOrganizationId: null,
- labComment: '',
detailClientIds: [],
toothProsthesis: [],
sentAt: null,
@@ -175,7 +173,6 @@ function mapLabCaseDraftFromApi(lc: PastLabCase): LabCaseDraft {
clientId: lc.clientId,
id: lc.id,
destinationOrganizationId: lc.destinationOrganizationId,
- labComment: lc.labComment ?? '',
detailClientIds: lc.details.map((d) => d.clientId),
toothProsthesis: (lc.toothProsthesis ?? []).map((tp) => ({
detailClientId:
@@ -391,6 +388,12 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
const selectedTeethSet = useMemo(() => new Set(activeDetail?.teeth ?? []), [activeDetail?.teeth]);
+ // Sync active lab shipment when the selected treatment detail changes.
+ useEffect(() => {
+ const match = labCaseDrafts.find((lc) => lc.detailClientIds.includes(activeDetailId));
+ setActiveLabCaseId(match?.clientId ?? null);
+ }, [activeDetailId, labCaseDrafts]);
+
useEffect(() => {
setSelectionLocked(false);
}, [selectedDay]);
@@ -788,18 +791,18 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
);
const persistLabCases = useCallback(
- async (savedTreatment: PastTreatment) => {
+ async (savedTreatment: PastTreatment, draftsOverride?: LabCaseDraft[]) => {
if (!selectedAppointment) throw new Error('No appointment selected');
+ const drafts = draftsOverride ?? labCaseDrafts;
const detailIdByClientId = new Map(
savedTreatment.details.map((d) => [d.clientId, d.id]),
);
- const payload = labCaseDrafts.map((lc) => ({
+ const payload = drafts.map((lc) => ({
clientId: lc.clientId,
id: lc.id,
destinationOrganizationId: lc.destinationOrganizationId ?? undefined,
- labComment: lc.labComment.trim() || undefined,
treatmentDetailIds: lc.detailClientIds
.map((clientId) => detailIdByClientId.get(clientId))
.filter((id): id is string => Boolean(id)),
@@ -834,6 +837,40 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
[labCaseDrafts, selectedAppointment],
);
+ const handleAddLabCase = useCallback(async () => {
+ if (!canEditTreatmentForDay || !selectedAppointment) return;
+
+ const activeDetail = details.find((d) => d.clientId === activeDetailId);
+ const next: LabCaseDraft = {
+ ...newLabCaseDraft(),
+ detailClientIds:
+ activeDetail && labDependentCodes.has(activeDetail.treatmentType)
+ ? [activeDetailId]
+ : [],
+ };
+ const updatedLabCases = [...labCaseDrafts, next];
+ setLabCaseDrafts(updatedLabCases);
+ setActiveLabCaseId(next.clientId);
+
+ try {
+ const saved = await persistDraft({ force: true });
+ await persistLabCases(saved, updatedLabCases);
+ } catch (error: unknown) {
+ showError(formatApiErrorMessage(error, t('errorSaveLabShipments')));
+ }
+ }, [
+ activeDetailId,
+ canEditTreatmentForDay,
+ details,
+ labCaseDrafts,
+ labDependentCodes,
+ persistDraft,
+ persistLabCases,
+ selectedAppointment,
+ showError,
+ t,
+ ]);
+
const handleSendLabCase = useCallback(
async (labCase: LabCaseDraft) => {
if (!canEditTreatmentForDay || !selectedAppointment) return;
@@ -1041,6 +1078,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
{
- const next = newLabCaseDraft();
- setLabCaseDrafts((prev) => [...prev, next]);
- setActiveLabCaseId(next.clientId);
- }}
+ onAddLabCase={() => void handleAddLabCase()}
onSendLabCase={(lc) => void handleSendLabCase(lc)}
+ onCommentError={showError}
/>
diff --git a/frontend/src/lib/api/treatments.ts b/frontend/src/lib/api/treatments.ts
index 6dbb17d..6769458 100644
--- a/frontend/src/lib/api/treatments.ts
+++ b/frontend/src/lib/api/treatments.ts
@@ -1,4 +1,5 @@
import { apiClient } from './client';
+import type { LabCaseComment } from '@/types/cases';
import type {
LabCaseResponse,
LinkedOrganizationOption,
@@ -82,6 +83,21 @@ export const treatmentsApi = {
return response.data;
},
+ listLabCaseComments: async (
+ labCaseId: string,
+ ): Promise<{ success: boolean; data: LabCaseComment[] }> => {
+ const response = await apiClient.get(`/treatments/lab-cases/${labCaseId}/comments`);
+ return response.data;
+ },
+
+ addLabCaseComment: async (
+ labCaseId: string,
+ payload: { body: string },
+ ): Promise<{ success: boolean; data: LabCaseComment }> => {
+ const response = await apiClient.post(`/treatments/lab-cases/${labCaseId}/comments`, payload);
+ return response.data;
+ },
+
getAttachmentFileBlob: async (attachmentId: string): Promise => {
const response = await apiClient.get(`/treatments/attachments/${attachmentId}/file`, {
responseType: 'blob',
diff --git a/frontend/src/types/cases.ts b/frontend/src/types/cases.ts
index 3fd34a1..954be9b 100644
--- a/frontend/src/types/cases.ts
+++ b/frontend/src/types/cases.ts
@@ -63,12 +63,12 @@ export interface LabCaseComment {
visibleToClinic: boolean;
createdAt: string;
canToggleVisibility: boolean;
+ showVisibilityStatus?: boolean;
}
export interface LabCaseDetail {
id: string;
sentAt: string | null;
- labComment: string | null;
clinic: { id: string; name: string };
patient: {
id: string;
diff --git a/frontend/src/types/treatment.ts b/frontend/src/types/treatment.ts
index 6c5d5a4..bf6c46f 100644
--- a/frontend/src/types/treatment.ts
+++ b/frontend/src/types/treatment.ts
@@ -89,7 +89,6 @@ export interface PastLabCase {
id: string;
clientId: string;
destinationOrganizationId: string | null;
- labComment?: string | null;
sentAt?: string | null;
treatmentDetailIds: string[];
details: Array<{
@@ -143,7 +142,6 @@ export interface LabCaseDraft {
clientId: string;
id?: string;
destinationOrganizationId: string | null;
- labComment: string;
detailClientIds: string[];
toothProsthesis: LabCaseToothProsthesisDraft[];
sentAt?: string | null;
@@ -166,7 +164,6 @@ export interface SaveLabCasePayload {
clientId: string;
id?: string;
destinationOrganizationId?: string;
- labComment?: string;
treatmentDetailIds: string[];
toothProsthesis?: Array<{
treatmentDetailId: string;
@@ -185,7 +182,6 @@ export interface LabCaseResponse {
id: string;
clientId: string;
destinationOrganizationId: string | null;
- labComment: string | null;
sentAt: string | null;
treatmentDetailIds: string[];
details: Array<{
--
2.53.0.windows.1
From 86b1e3afffcb3692cc680f2e6b5dfc6a8060be58 Mon Sep 17 00:00:00 2001
From: Admin
Date: Tue, 7 Jul 2026 18:43:10 +0330
Subject: [PATCH 16/17] improvement: attachment selection for lab dispatch
added. attachment preview added to cases feature.
---
.../migration.sql | 16 ++
backend/prisma/reset-treatment-data.ts | 2 +
backend/prisma/schema.prisma | 14 ++
.../scripts/clear-clinical-test-data.ts | 12 +-
backend/src/modules/cases/cases.controller.ts | 22 +++
backend/src/modules/cases/cases.service.ts | 64 +++++++
.../modules/treatments/dto/treatment.dto.ts | 5 +
.../modules/treatments/treatments.service.ts | 46 +++++
frontend/messages/en.json | 5 +
frontend/messages/fa.json | 5 +
frontend/messages/nl.json | 5 +
.../app/[locale]/(dashboard)/cases/page.tsx | 61 +++++-
.../app/[locale]/(dashboard)/tasks/page.tsx | 14 +-
.../components/ui/lab/CaseToothChartPanel.tsx | 63 +++++++
.../ui/lab/LabCaseAttachmentPreview.tsx | 67 +++++++
.../components/ui/lab/labTaskStatusDisplay.ts | 28 ++-
.../ConnectionCaseHistoryContent.tsx | 64 ++++++-
frontend/src/components/ui/shared/Badge.tsx | 19 +-
.../components/ui/treatment/FdiToothChart.tsx | 174 +++++++++++++-----
.../ui/treatment/LabCasesDispatchPanel.tsx | 30 +++
.../components/ui/treatment/ToothGlyph.tsx | 55 +++++-
.../ui/treatment/TreatmentWorkspace.tsx | 54 +++++-
frontend/src/lib/api/cases.ts | 8 +
frontend/src/types/cases.ts | 14 ++
frontend/src/types/treatment.ts | 4 +
25 files changed, 767 insertions(+), 84 deletions(-)
create mode 100644 backend/prisma/migrations/20260707160000_lab_case_attachments/migration.sql
create mode 100644 frontend/src/components/ui/lab/CaseToothChartPanel.tsx
create mode 100644 frontend/src/components/ui/lab/LabCaseAttachmentPreview.tsx
diff --git a/backend/prisma/migrations/20260707160000_lab_case_attachments/migration.sql b/backend/prisma/migrations/20260707160000_lab_case_attachments/migration.sql
new file mode 100644
index 0000000..ed71309
--- /dev/null
+++ b/backend/prisma/migrations/20260707160000_lab_case_attachments/migration.sql
@@ -0,0 +1,16 @@
+-- Per-shipment attachment selection: only checked files are visible to the lab.
+CREATE TABLE "lab_case_attachments" (
+ "labCaseId" TEXT NOT NULL,
+ "attachmentId" TEXT NOT NULL,
+ CONSTRAINT "lab_case_attachments_pkey" PRIMARY KEY ("labCaseId", "attachmentId")
+);
+
+CREATE INDEX "lab_case_attachments_attachmentId_idx" ON "lab_case_attachments"("attachmentId");
+
+ALTER TABLE "lab_case_attachments"
+ ADD CONSTRAINT "lab_case_attachments_labCaseId_fkey"
+ FOREIGN KEY ("labCaseId") REFERENCES "lab_cases"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+ALTER TABLE "lab_case_attachments"
+ ADD CONSTRAINT "lab_case_attachments_attachmentId_fkey"
+ FOREIGN KEY ("attachmentId") REFERENCES "treatment_detail_attachments"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/backend/prisma/reset-treatment-data.ts b/backend/prisma/reset-treatment-data.ts
index 1f3bfa5..4098bf0 100644
--- a/backend/prisma/reset-treatment-data.ts
+++ b/backend/prisma/reset-treatment-data.ts
@@ -23,6 +23,7 @@ const prisma = new PrismaClient();
const TABLES_IN_ORDER = [
'lab_case_task_status_events',
'lab_case_comments',
+ 'lab_case_attachments',
'lab_case_tasks',
'lab_case_sends',
'lab_case_tooth_prosthesis',
@@ -31,6 +32,7 @@ const TABLES_IN_ORDER = [
'treatment_detail_attachments',
'treatment_details',
'treatments',
+ 'appointments',
];
async function tableExists(table: string): Promise {
diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma
index 4494397..3e7c2df 100644
--- a/backend/prisma/schema.prisma
+++ b/backend/prisma/schema.prisma
@@ -179,6 +179,7 @@ model TreatmentDetailAttachment {
storagePath String
detail TreatmentDetail? @relation(fields: [detailId], references: [id], onDelete: Cascade)
+ labCaseLinks LabCaseAttachment[]
createdAt DateTime @default(now())
@@ -201,11 +202,24 @@ model LabCase {
tasks LabCaseTask[]
toothProsthesis LabCaseToothProsthesis[]
comments LabCaseComment[]
+ attachments LabCaseAttachment[]
@@index([treatmentId, sortOrder])
@@map("lab_cases")
}
+model LabCaseAttachment {
+ labCaseId String
+ attachmentId String
+
+ labCase LabCase @relation(fields: [labCaseId], references: [id], onDelete: Cascade)
+ attachment TreatmentDetailAttachment @relation(fields: [attachmentId], references: [id], onDelete: Cascade)
+
+ @@id([labCaseId, attachmentId])
+ @@index([attachmentId])
+ @@map("lab_case_attachments")
+}
+
model LabCaseDetail {
labCaseId String
treatmentDetailId String @unique
diff --git a/backend/prisma/scripts/clear-clinical-test-data.ts b/backend/prisma/scripts/clear-clinical-test-data.ts
index 91bf5ff..99cee7c 100644
--- a/backend/prisma/scripts/clear-clinical-test-data.ts
+++ b/backend/prisma/scripts/clear-clinical-test-data.ts
@@ -14,8 +14,12 @@ const prisma = new PrismaClient();
async function main() {
const counts = {
+ labCaseTaskStatusEvents: await prisma.labCaseTaskStatusEvent.count(),
+ labCaseComments: await prisma.labCaseComment.count(),
+ labCaseAttachments: await prisma.labCaseAttachment.count(),
labCaseTasks: await prisma.labCaseTask.count(),
labCaseSends: await prisma.labCaseSend.count(),
+ labCaseToothProsthesis: await prisma.labCaseToothProsthesis.count(),
labCaseDetails: await prisma.labCaseDetail.count(),
labCases: await prisma.labCase.count(),
attachments: await prisma.treatmentDetailAttachment.count(),
@@ -27,8 +31,12 @@ async function main() {
console.log('Current row counts:', counts);
await prisma.$transaction([
+ prisma.labCaseTaskStatusEvent.deleteMany(),
+ prisma.labCaseComment.deleteMany(),
+ prisma.labCaseAttachment.deleteMany(),
prisma.labCaseTask.deleteMany(),
prisma.labCaseSend.deleteMany(),
+ prisma.labCaseToothProsthesis.deleteMany(),
prisma.labCaseDetail.deleteMany(),
prisma.labCase.deleteMany(),
prisma.treatmentDetailAttachment.deleteMany(),
@@ -37,7 +45,9 @@ async function main() {
prisma.appointment.deleteMany(),
]);
- console.log('✅ Cleared appointments, treatments, lab cases, tasks, and attachments.');
+ console.log(
+ '✅ Cleared appointments, treatments, lab cases, tasks, comments, attachments, and related rows.',
+ );
}
main()
diff --git a/backend/src/modules/cases/cases.controller.ts b/backend/src/modules/cases/cases.controller.ts
index 00ecae5..8b319d6 100644
--- a/backend/src/modules/cases/cases.controller.ts
+++ b/backend/src/modules/cases/cases.controller.ts
@@ -6,8 +6,10 @@ import {
Patch,
Query,
Req,
+ Res,
UseGuards,
} from '@nestjs/common';
+import type { Response } from 'express';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { LabOrgGuard } from '../../common/guards/lab-org.guard';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
@@ -42,6 +44,26 @@ export class CasesController {
return this.casesService.getOne(id, organizationId, req.user.id, req.user.language);
}
+ @Get(':id/attachments/:attachmentId/file')
+ @ApiOperation({ summary: 'Download an attachment shared with this lab case' })
+ async downloadAttachment(
+ @Param('id') id: string,
+ @Param('attachmentId') attachmentId: string,
+ @Req() req,
+ @Res() res: Response,
+ ) {
+ const organizationId = this.casesService.getOrganizationIdFromUser(req.user);
+ const file = await this.casesService.streamCaseAttachment(
+ id,
+ attachmentId,
+ organizationId,
+ req.user.id,
+ );
+ res.setHeader('Content-Type', file.mimeType);
+ res.setHeader('Content-Disposition', `inline; filename="${file.fileName}"`);
+ file.stream.pipe(res);
+ }
+
@Patch(':id/tasks/:taskId')
@ApiOperation({ summary: 'Toggle task important flag' })
updateTask(
diff --git a/backend/src/modules/cases/cases.service.ts b/backend/src/modules/cases/cases.service.ts
index 5404942..d7fe45c 100644
--- a/backend/src/modules/cases/cases.service.ts
+++ b/backend/src/modules/cases/cases.service.ts
@@ -4,6 +4,7 @@ import {
Injectable,
NotFoundException,
} from '@nestjs/common';
+import { createReadStream, existsSync } from 'fs';
import { CatalogEntityKind, LabTaskStatus, Prisma } from '@prisma/client';
import { PrismaService } from '../../../prisma/prisma.service';
import { normalizeMobile } from '../../common/phone';
@@ -54,6 +55,20 @@ const labCaseListInclude = {
},
},
},
+ toothProsthesis: true,
+ attachments: {
+ include: {
+ attachment: {
+ select: {
+ id: true,
+ fileName: true,
+ mimeType: true,
+ sizeBytes: true,
+ createdAt: true,
+ },
+ },
+ },
+ },
} satisfies Prisma.LabCaseInclude;
type LabCaseTaskWithRelations = Prisma.LabCaseTaskGetPayload<{
@@ -283,6 +298,43 @@ export class CasesService {
return { success: true, data: await this.mapLabCaseDetail(labCase, localeInput) };
}
+ async streamCaseAttachment(
+ labCaseId: string,
+ attachmentId: string,
+ labOrganizationId: string,
+ actorUserId: string,
+ ) {
+ await this.assertCanReadCases(actorUserId, labOrganizationId);
+
+ const link = await this.prisma.labCaseAttachment.findFirst({
+ where: {
+ labCaseId,
+ attachmentId,
+ labCase: {
+ sentAt: { not: null },
+ sends: { some: { organizationId: labOrganizationId } },
+ },
+ },
+ include: {
+ attachment: { select: { storagePath: true, fileName: true, mimeType: true } },
+ },
+ });
+
+ if (!link?.attachment) {
+ throw new NotFoundException('Attachment not found');
+ }
+
+ if (!existsSync(link.attachment.storagePath)) {
+ throw new NotFoundException('Attachment file is missing on disk');
+ }
+
+ return {
+ stream: createReadStream(link.attachment.storagePath),
+ fileName: link.attachment.fileName,
+ mimeType: link.attachment.mimeType,
+ };
+ }
+
async updateTask(
labCaseId: string,
taskId: string,
@@ -457,6 +509,18 @@ export class CasesService {
teeth: normalizeTeeth(link.detail.teeth),
comment: link.detail.comment,
})),
+ toothProsthesis: lc.toothProsthesis.map((row) => ({
+ treatmentDetailId: row.treatmentDetailId,
+ tooth: row.tooth,
+ prosthesisTypeCode: row.prosthesisTypeCode,
+ })),
+ attachments: lc.attachments.map((row) => ({
+ id: row.attachment.id,
+ fileName: row.attachment.fileName,
+ mimeType: row.attachment.mimeType,
+ sizeBytes: row.attachment.sizeBytes,
+ createdAt: row.attachment.createdAt.toISOString(),
+ })),
sends: lc.sends.map((s) => ({
organizationId: s.organizationId,
organizationName: s.organization.name,
diff --git a/backend/src/modules/treatments/dto/treatment.dto.ts b/backend/src/modules/treatments/dto/treatment.dto.ts
index 0e38e8d..c0e7a64 100644
--- a/backend/src/modules/treatments/dto/treatment.dto.ts
+++ b/backend/src/modules/treatments/dto/treatment.dto.ts
@@ -81,6 +81,11 @@ export class SaveLabCaseDto {
@ValidateNested({ each: true })
@Type(() => LabCaseToothProsthesisDto)
toothProsthesis?: LabCaseToothProsthesisDto[];
+
+ @IsOptional()
+ @IsArray()
+ @IsUUID(undefined, { each: true })
+ attachmentIds?: string[];
}
export class SaveTreatmentLabCasesDto {
diff --git a/backend/src/modules/treatments/treatments.service.ts b/backend/src/modules/treatments/treatments.service.ts
index 1964e1c..0fed982 100644
--- a/backend/src/modules/treatments/treatments.service.ts
+++ b/backend/src/modules/treatments/treatments.service.ts
@@ -56,6 +56,13 @@ const treatmentInclude = {
include: { organization: { select: { id: true, name: true } } },
},
toothProsthesis: true,
+ attachments: {
+ include: {
+ attachment: {
+ select: { id: true, fileName: true, mimeType: true, sizeBytes: true, createdAt: true },
+ },
+ },
+ },
},
},
};
@@ -427,6 +434,29 @@ export class TreatmentsService {
})),
});
}
+
+ await tx.labCaseAttachment.deleteMany({ where: { labCaseId: row.id } });
+ const attachmentIds = lc.attachmentIds ?? [];
+ if (attachmentIds.length > 0) {
+ const validAttachments = await tx.treatmentDetailAttachment.findMany({
+ where: {
+ id: { in: attachmentIds },
+ detailId: { in: lc.treatmentDetailIds },
+ },
+ select: { id: true },
+ });
+ if (validAttachments.length !== attachmentIds.length) {
+ throw new BadRequestException(
+ 'One or more attachments are invalid for this lab case',
+ );
+ }
+ await tx.labCaseAttachment.createMany({
+ data: attachmentIds.map((attachmentId) => ({
+ labCaseId: row.id,
+ attachmentId,
+ })),
+ });
+ }
}
return tx.treatment.findUniqueOrThrow({
@@ -766,6 +796,15 @@ export class TreatmentsService {
tooth: string;
prosthesisTypeCode: string;
}>;
+ attachments?: Array<{
+ attachment: {
+ id: string;
+ fileName: string;
+ mimeType: string;
+ sizeBytes: number;
+ createdAt: Date;
+ };
+ }>;
}) {
return {
id: lc.id,
@@ -784,6 +823,13 @@ export class TreatmentsService {
tooth: tp.tooth,
prosthesisTypeCode: tp.prosthesisTypeCode,
})),
+ attachments: (lc.attachments ?? []).map((row) => ({
+ id: row.attachment.id,
+ fileName: row.attachment.fileName,
+ mimeType: row.attachment.mimeType,
+ sizeBytes: row.attachment.sizeBytes,
+ createdAt: row.attachment.createdAt.toISOString(),
+ })),
sends:
lc.sends?.map((s) => ({
organizationId: s.organizationId,
diff --git a/frontend/messages/en.json b/frontend/messages/en.json
index 5afd92b..532a3e0 100644
--- a/frontend/messages/en.json
+++ b/frontend/messages/en.json
@@ -353,6 +353,7 @@
"patientMobile": "Mobile",
"showComments": "Comments",
"commentsCount": "Comments ({count})",
+ "latestAttachment": "Latest file",
"prevPage": "Previous",
"nextPage": "Next",
"pageSummary": "Page {page} of {totalPages} ({total} cases)",
@@ -583,7 +584,11 @@
"noActiveOrgs": "No active linked organizations.",
"confirmSend": "Confirm send",
"toothChartTitle": "FDI tooth chart",
+ "toothChartTitleCompact": "Tooth chart",
"toothChartHint": "Tap teeth to multi-select. Applies to the active detail.",
+ "toothChartWholePlan": "Show whole treatment plan",
+ "labShipmentAttachments": "Files for the lab",
+ "labShipmentAttachmentsHint": "Select which attachments from this detail are included in this shipment. None are sent by default.",
"selectedLabel": "Selected:",
"selectedEmpty": "—",
"upperArch": "Upper arch",
diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json
index e67a52f..d8e53b3 100644
--- a/frontend/messages/fa.json
+++ b/frontend/messages/fa.json
@@ -353,6 +353,7 @@
"patientMobile": "موبایل",
"showComments": "نظرات",
"commentsCount": "نظرات ({count})",
+ "latestAttachment": "آخرین فایل",
"prevPage": "قبلی",
"nextPage": "بعدی",
"pageSummary": "صفحه {page} از {totalPages} ({total} پرونده)",
@@ -583,7 +584,11 @@
"noActiveOrgs": "هیچ سازمان مرتبط فعالی وجود ندارد.",
"confirmSend": "تأیید ارسال",
"toothChartTitle": "نمودار دندانها FDI",
+ "toothChartTitleCompact": "نمودار دندان",
"toothChartHint": "برای انتخاب چندگانه روی دندانها ضربه بزنید. برای جزئیات فعال اعمال میشود.",
+ "toothChartWholePlan": "نمایش کل طرح درمان",
+ "labShipmentAttachments": "فایلها برای لابراتوار",
+ "labShipmentAttachmentsHint": "انتخاب کنید کدام پیوستهای این جزئیات در این محموله ارسال شوند. پیشفرض هیچکدام نیست.",
"selectedLabel": "انتخاب شده:",
"selectedEmpty": "—",
"upperArch": "قوس بالا",
diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json
index 9aaf99e..786520f 100644
--- a/frontend/messages/nl.json
+++ b/frontend/messages/nl.json
@@ -353,6 +353,7 @@
"patientMobile": "Mobiel",
"showComments": "Opmerkingen",
"commentsCount": "Opmerkingen ({count})",
+ "latestAttachment": "Laatste bestand",
"prevPage": "Vorige",
"nextPage": "Volgende",
"pageSummary": "Pagina {page} van {totalPages} ({total} dossiers)",
@@ -583,7 +584,11 @@
"noActiveOrgs": "Geen actieve gekoppelde organisaties.",
"confirmSend": "Bevestig verzending",
"toothChartTitle": "FDI-tanddiagram",
+ "toothChartTitleCompact": "Tanddiagram",
"toothChartHint": "Tik op tanden om meerdere te selecteren. Geldt voor het actieve detail.",
+ "toothChartWholePlan": "Hele behandelplan tonen",
+ "labShipmentAttachments": "Bestanden voor het lab",
+ "labShipmentAttachmentsHint": "Kies welke bijlagen van dit detail bij deze zending horen. Standaard worden er geen meegestuurd.",
"selectedLabel": "Geselecteerd:",
"selectedEmpty": "—",
"upperArch": "Bovenboog",
diff --git a/frontend/src/app/[locale]/(dashboard)/cases/page.tsx b/frontend/src/app/[locale]/(dashboard)/cases/page.tsx
index ad13b3f..c274887 100644
--- a/frontend/src/app/[locale]/(dashboard)/cases/page.tsx
+++ b/frontend/src/app/[locale]/(dashboard)/cases/page.tsx
@@ -11,6 +11,8 @@ import { useToast } from '@/lib/hooks/useToast';
import { canEditCases, canEditTasks } from '@/components/shared/permissions';
import { Badge } from '@/components/ui/shared/Badge';
import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel';
+import { CaseToothChartPanel } from '@/components/ui/lab/CaseToothChartPanel';
+import { LabCaseAttachmentPreview } from '@/components/ui/lab/LabCaseAttachmentPreview';
import { labTaskStatusVariant } from '@/components/ui/lab/labTaskStatusDisplay';
import { casesApi } from '@/lib/api/cases';
import { tasksApi } from '@/lib/api/tasks';
@@ -210,6 +212,39 @@ export default function CasesPage() {
document.getElementById('case-comments')?.scrollIntoView({ behavior: 'smooth' });
}
+ const loadCaseAttachmentBlob = useCallback(
+ (caseId: string, attachmentId: string) => casesApi.getAttachmentFileBlob(caseId, attachmentId),
+ [],
+ );
+
+ const latestCaseAttachment = useMemo(() => {
+ if (!selectedCase?.attachments.length) return null;
+ return [...selectedCase.attachments].sort(
+ (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
+ )[0];
+ }, [selectedCase?.attachments]);
+
+ const caseProsthesisRows = useMemo(() => {
+ if (!selectedCase) return [];
+ if (selectedCase.toothProsthesis.length > 0) {
+ const byCode = new Map();
+ for (const row of selectedCase.toothProsthesis) {
+ const key = row.prosthesisTypeCode;
+ const teeth = byCode.get(key) ?? [];
+ if (!teeth.includes(row.tooth)) teeth.push(row.tooth);
+ byCode.set(key, teeth);
+ }
+ return [...byCode.entries()].map(([prosthesisTypeCode, teeth]) => ({
+ prosthesisTypeCode,
+ teeth,
+ }));
+ }
+ return selectedCase.tasksByTooth.map((g) => ({
+ prosthesisTypeCode: g.prosthesisTypeCode,
+ teeth: g.teeth,
+ }));
+ }, [selectedCase]);
+
function clearFilters() {
setSearch('');
setClinicId('');
@@ -446,6 +481,25 @@ export default function CasesPage() {
+
+
+ {latestCaseAttachment && selectedCaseId ? (
+
+
{t('latestAttachment')}
+
+
+ ) : null}
+
+
{selectedCase.details.length > 0 && (
{t('treatmentDetails')}
@@ -476,12 +530,13 @@ export default function CasesPage() {
className="rounded-md border border-border p-3 space-y-2"
>
-
{group.prosthesisTypeLabel}
-
+
{t('toothGroupTitle', {
teeth: formatToothList(group.teeth),
diff --git a/frontend/src/app/[locale]/(dashboard)/tasks/page.tsx b/frontend/src/app/[locale]/(dashboard)/tasks/page.tsx
index 8b2837e..64e9d36 100644
--- a/frontend/src/app/[locale]/(dashboard)/tasks/page.tsx
+++ b/frontend/src/app/[locale]/(dashboard)/tasks/page.tsx
@@ -10,7 +10,7 @@ import { FORM_SELECT_CLASS } from '@/components/ui/shared/formSelectStyles';
import { SearchBar } from '@/components/ui/shared/SearchBar';
import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel';
import {
- labTaskStatusSelectClass,
+ labTaskStatusSelectStyle,
labTaskStatusVariant,
} from '@/components/ui/lab/labTaskStatusDisplay';
import {
@@ -321,7 +321,8 @@ export default function TasksPage() {
onChange={(e) =>
void handleStatusUpdate(task.id, e.target.value as LabTaskStatus)
}
- className={`${FORM_SELECT_CLASS} w-full max-w-[132px] ${labTaskStatusSelectClass(task.status)}`}
+ className={`${FORM_SELECT_CLASS} w-full max-w-[132px] font-medium`}
+ style={labTaskStatusSelectStyle(task.status)}
>
{statusOptions.map((opt) => (
@@ -354,12 +355,15 @@ export default function TasksPage() {
) : null}
-
{task.prosthesisTypeLabel}
-
+
diff --git a/frontend/src/components/ui/lab/CaseToothChartPanel.tsx b/frontend/src/components/ui/lab/CaseToothChartPanel.tsx
new file mode 100644
index 0000000..b9fd80d
--- /dev/null
+++ b/frontend/src/components/ui/lab/CaseToothChartPanel.tsx
@@ -0,0 +1,63 @@
+'use client';
+
+import { useMemo } from 'react';
+import { FdiToothChart } from '@/components/ui/treatment/FdiToothChart';
+import { prosthesisTypeColor } from '@/components/ui/treatment/prosthesisTypeDisplay';
+import type { FdiToothId } from '@/types/treatment';
+
+export interface CaseToothChartDetail {
+ teeth: string[];
+}
+
+export interface CaseToothChartProsthesisRow {
+ teeth: string[];
+ prosthesisTypeCode: string;
+}
+
+interface CaseToothChartPanelProps {
+ details: CaseToothChartDetail[];
+ /** Prosthesis mapping from case tasks or toothProsthesis rows. */
+ prosthesisRows: CaseToothChartProsthesisRow[];
+ scale?: number;
+ className?: string;
+}
+
+/** Read-only FDI chart for lab case detail — prosthesis-type glow on selected teeth. */
+export function CaseToothChartPanel({
+ details,
+ prosthesisRows,
+ scale = 0.5,
+ className = '',
+}: CaseToothChartPanelProps) {
+ const selected = useMemo(() => {
+ const set = new Set();
+ for (const detail of details) {
+ for (const tooth of detail.teeth) set.add(tooth as FdiToothId);
+ }
+ return set;
+ }, [details]);
+
+ const toothColors = useMemo(() => {
+ const colors: Partial> = {};
+ prosthesisRows.forEach((row, index) => {
+ const color = prosthesisTypeColor(row.prosthesisTypeCode, index);
+ for (const tooth of row.teeth) {
+ colors[tooth as FdiToothId] = color;
+ }
+ });
+ return colors;
+ }, [prosthesisRows]);
+
+ if (selected.size === 0) return null;
+
+ return (
+
+ );
+}
diff --git a/frontend/src/components/ui/lab/LabCaseAttachmentPreview.tsx b/frontend/src/components/ui/lab/LabCaseAttachmentPreview.tsx
new file mode 100644
index 0000000..00a2f82
--- /dev/null
+++ b/frontend/src/components/ui/lab/LabCaseAttachmentPreview.tsx
@@ -0,0 +1,67 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import { FileText } from 'lucide-react';
+import type { LabCaseAttachmentMeta } from '@/types/cases';
+
+interface LabCaseAttachmentPreviewProps {
+ caseId: string;
+ attachment: LabCaseAttachmentMeta;
+ loadBlob: (caseId: string, attachmentId: string) => Promise;
+ className?: string;
+}
+
+export function LabCaseAttachmentPreview({
+ caseId,
+ attachment,
+ loadBlob,
+ className = 'aspect-square w-full max-w-[11rem]',
+}: LabCaseAttachmentPreviewProps) {
+ const [url, setUrl] = useState(null);
+ const [failed, setFailed] = useState(false);
+
+ useEffect(() => {
+ let cancelled = false;
+ let objectUrl: string | null = null;
+
+ void (async () => {
+ try {
+ const blob = await loadBlob(caseId, attachment.id);
+ if (cancelled) return;
+ objectUrl = URL.createObjectURL(blob);
+ setUrl(objectUrl);
+ setFailed(false);
+ } catch {
+ if (!cancelled) setFailed(true);
+ }
+ })();
+
+ return () => {
+ cancelled = true;
+ if (objectUrl) URL.revokeObjectURL(objectUrl);
+ };
+ }, [caseId, attachment.id, loadBlob]);
+
+ const isImage = attachment.mimeType.startsWith('image/');
+ const isPdf = attachment.mimeType === 'application/pdf';
+
+ return (
+
+ {url && isImage ? (
+
+ ) : url && isPdf ? (
+
+ ) : (
+
+
+
+ {failed ? 'Preview unavailable' : attachment.fileName}
+
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/ui/lab/labTaskStatusDisplay.ts b/frontend/src/components/ui/lab/labTaskStatusDisplay.ts
index e0b9783..f96f8b9 100644
--- a/frontend/src/components/ui/lab/labTaskStatusDisplay.ts
+++ b/frontend/src/components/ui/lab/labTaskStatusDisplay.ts
@@ -1,17 +1,25 @@
+import type { CSSProperties } from 'react';
import type { BadgeVariant } from '@/components/ui/shared/Badge';
import type { LabTaskStatus } from '@/types/cases';
export function labTaskStatusVariant(status: LabTaskStatus): BadgeVariant {
- return status === 'COMPLETED' ? 'success' : 'default';
+ return status === 'COMPLETED' ? 'success' : 'warning';
}
-export function labTaskStatusSelectClass(status: LabTaskStatus): string {
- switch (status) {
- case 'COMPLETED':
- return 'border-success/60 text-success';
- case 'IN_PROGRESS':
- return 'border-primary/60 text-primary';
- default:
- return '';
- }
+/**
+ * Inline style for the closed status so its text/border reflect the
+ * current value (yellow = in progress, green = completed). Uses the same badge
+ * token colors as the badges for consistency. Native colors have
+ * limited cross-browser support, so only the closed control is themed.
+ */
+export function labTaskStatusSelectStyle(status: LabTaskStatus): CSSProperties {
+ const color =
+ status === 'COMPLETED'
+ ? 'var(--color-badge-success-fg)'
+ : 'var(--color-badge-warning-fg)';
+ const borderColor =
+ status === 'COMPLETED'
+ ? 'var(--color-badge-success-border)'
+ : 'var(--color-badge-warning-border)';
+ return { color, borderColor };
}
diff --git a/frontend/src/components/ui/organizations/ConnectionCaseHistoryContent.tsx b/frontend/src/components/ui/organizations/ConnectionCaseHistoryContent.tsx
index 438d4d5..696c191 100644
--- a/frontend/src/components/ui/organizations/ConnectionCaseHistoryContent.tsx
+++ b/frontend/src/components/ui/organizations/ConnectionCaseHistoryContent.tsx
@@ -14,11 +14,14 @@ import { Button } from '@/components/ui/shared/Button';
import { SearchBar } from '@/components/ui/shared/SearchBar';
import { ToastStack } from '@/components/ui/shared/Toast';
import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel';
+import { CaseToothChartPanel } from '@/components/ui/lab/CaseToothChartPanel';
+import { LabCaseAttachmentPreview } from '@/components/ui/lab/LabCaseAttachmentPreview';
import { labTaskStatusVariant } from '@/components/ui/lab/labTaskStatusDisplay';
import {
formatToothList,
prosthesisTypeBadgeStyle,
} from '@/components/ui/treatment/prosthesisTypeDisplay';
+import { treatmentsApi } from '@/lib/api/treatments';
import type { CounterpartItemDto } from '@/lib/api/organization';
import type { LabCaseDetail, LabCaseListItem, LabTaskStatus } from '@/types/cases';
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
@@ -186,6 +189,39 @@ export function ConnectionCaseHistoryContent({
document.getElementById('case-comments')?.scrollIntoView({ behavior: 'smooth' });
}
+ const loadClinicAttachmentBlob = useCallback(
+ (_caseId: string, attachmentId: string) => treatmentsApi.getAttachmentFileBlob(attachmentId),
+ [],
+ );
+
+ const latestCaseAttachment = useMemo(() => {
+ if (!selectedCase?.attachments.length) return null;
+ return [...selectedCase.attachments].sort(
+ (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
+ )[0];
+ }, [selectedCase?.attachments]);
+
+ const caseProsthesisRows = useMemo(() => {
+ if (!selectedCase) return [];
+ if (selectedCase.toothProsthesis.length > 0) {
+ const byCode = new Map();
+ for (const row of selectedCase.toothProsthesis) {
+ const key = row.prosthesisTypeCode;
+ const teeth = byCode.get(key) ?? [];
+ if (!teeth.includes(row.tooth)) teeth.push(row.tooth);
+ byCode.set(key, teeth);
+ }
+ return [...byCode.entries()].map(([prosthesisTypeCode, teeth]) => ({
+ prosthesisTypeCode,
+ teeth,
+ }));
+ }
+ return selectedCase.tasksByTooth.map((g) => ({
+ prosthesisTypeCode: g.prosthesisTypeCode,
+ teeth: g.teeth,
+ }));
+ }, [selectedCase]);
+
return (
@@ -348,6 +384,27 @@ export function ConnectionCaseHistoryContent({
+
+
+ {latestCaseAttachment && selectedCaseId ? (
+
+
+ {tCases('latestAttachment')}
+
+
+
+ ) : null}
+
+
{selectedCase.details.length > 0 && (
@@ -383,12 +440,13 @@ export function ConnectionCaseHistoryContent({
className="rounded-md border border-border p-3 space-y-2"
>
-
{group.prosthesisTypeLabel}
-
+
{tCases('toothGroupTitle', {
teeth: formatToothList(group.teeth),
diff --git a/frontend/src/components/ui/shared/Badge.tsx b/frontend/src/components/ui/shared/Badge.tsx
index 642840d..23077f9 100644
--- a/frontend/src/components/ui/shared/Badge.tsx
+++ b/frontend/src/components/ui/shared/Badge.tsx
@@ -11,6 +11,15 @@ interface BadgeProps {
* Set false only when the pill should shrink to the label.
*/
fixedWidth?: boolean;
+ /**
+ * Inline style overriding the variant colors — e.g. dynamic prosthesis-type
+ * pastels via `prosthesisTypeBadgeStyle(code)`. Wins over variant classes.
+ */
+ style?: React.CSSProperties;
+ /** Native tooltip, useful when the label may be truncated. */
+ title?: string;
+ /** Clip an over-long label with an ellipsis instead of wrapping/overflowing. */
+ truncate?: boolean;
}
const variantStyles: Record = {
@@ -29,16 +38,22 @@ export function Badge({
variant = 'default',
className,
fixedWidth = true,
+ style,
+ title,
+ truncate = false,
}: BadgeProps) {
const layoutClass = fixedWidth
? `${FIXED_LAYOUT_CLASS} justify-center text-center`
: 'min-h-[1.75rem] px-2.5 py-1 justify-center';
+ const wrapClass = truncate ? '' : 'whitespace-nowrap';
return (
- {children}
+ {truncate ? {children} : children}
);
}
diff --git a/frontend/src/components/ui/treatment/FdiToothChart.tsx b/frontend/src/components/ui/treatment/FdiToothChart.tsx
index ce89280..05436a0 100644
--- a/frontend/src/components/ui/treatment/FdiToothChart.tsx
+++ b/frontend/src/components/ui/treatment/FdiToothChart.tsx
@@ -1,6 +1,6 @@
'use client';
-import { useId } from 'react';
+import { useId, type CSSProperties, type ReactNode } from 'react';
import { useTranslations } from 'next-intl';
import { FDI_LOWER_LEFT_TO_RIGHT, FDI_UPPER_LEFT_TO_RIGHT, getToothShapeKind } from '@/components/treatment/fdiToothMeta';
import { ToothGlyph } from '@/components/ui/treatment/ToothGlyph';
@@ -21,14 +21,37 @@ function quadrantMirrored(fdi: FdiToothId): boolean {
interface FdiToothChartProps {
selected: ReadonlySet;
- onToggle: (fdi: FdiToothId) => void;
+ onToggle?: (fdi: FdiToothId) => void;
disabled?: boolean;
+ /** Non-interactive display (Cases / connection history). */
+ readOnly?: boolean;
+ /** Visual scale via CSS zoom (0.5 = half size). */
+ scale?: number;
+ /** Per-tooth accent color for selected glow (prosthesis / treatment-type palettes). */
+ toothColors?: Partial>;
+ /** Extra control rendered in the chart header (e.g. whole-plan checkbox). */
+ headerControl?: ReactNode;
+ /** Compact card for embedded case detail panels. */
+ compact?: boolean;
+ className?: string;
}
-export function FdiToothChart({ selected, onToggle, disabled }: FdiToothChartProps) {
+export function FdiToothChart({
+ selected,
+ onToggle,
+ disabled,
+ readOnly = false,
+ scale = 1,
+ toothColors,
+ headerControl,
+ compact = false,
+ className = '',
+}: FdiToothChartProps) {
const t = useTranslations('treatment');
const uid = useId().replace(/:/g, '');
const archPeak = 10;
+ const interactive = !readOnly && Boolean(onToggle);
+ const isDisabled = disabled || readOnly;
const TOOTH_TWEAKS: Record = {
'18': { glyph: 'w-[1.98rem] h-[4.65rem]', offset: 7, rotate: -11 },
@@ -104,6 +127,20 @@ export function FdiToothChart({ selected, onToggle, disabled }: FdiToothChartPro
return normalized * 6;
};
+ const toothAccent = (fdi: FdiToothId) => toothColors?.[fdi];
+
+ const numberColorClass = (fdi: FdiToothId, isSel: boolean) => {
+ if (!isSel) return 'text-text-muted';
+ const accent = toothAccent(fdi);
+ return accent ? '' : 'text-primary';
+ };
+
+ const numberStyle = (fdi: FdiToothId, isSel: boolean): CSSProperties | undefined => {
+ if (!isSel) return undefined;
+ const accent = toothAccent(fdi);
+ return accent ? { color: accent } : undefined;
+ };
+
const Row = ({ teeth, upper }: { teeth: FdiToothId[]; upper?: boolean }) => (
{teeth.map((fdi, i) => {
@@ -115,37 +152,54 @@ export function FdiToothChart({ selected, onToggle, disabled }: FdiToothChartPro
const offsetY = tweak ? tweak.offset : archOffset(i, teeth.length, upper);
const rotate = tweak ? tweak.rotate : archRotate(i, teeth.length);
const alignItems = upper ? 'items-end' : 'items-start';
+ const accent = toothAccent(fdi);
+
+ const glyph = (
+
+ );
+
return (
-
onToggle(fdi)}
- style={{ transform: `translateY(${offsetY}px) rotate(${rotate}deg)` }}
- className={`
- rounded-[var(--radius-sm)] p-0.5 transition-transform
- focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50
- ${disabled ? 'opacity-50 cursor-not-allowed' : 'hover:scale-105 active:scale-95'}
- `}
- aria-pressed={isSel}
- aria-label={
- isSel
- ? `${t('toothAria', { fdi })}${t('toothSelectedSuffix')}`
- : t('toothAria', { fdi })
- }
- >
-
-
+ {interactive ? (
+
onToggle?.(fdi)}
+ style={{ transform: `translateY(${offsetY}px) rotate(${rotate}deg)` }}
+ className={`
+ rounded-[var(--radius-sm)] p-0.5 transition-transform
+ focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50
+ ${isDisabled ? 'opacity-50 cursor-not-allowed' : 'hover:scale-105 active:scale-95'}
+ `}
+ aria-pressed={isSel}
+ aria-label={
+ isSel
+ ? `${t('toothAria', { fdi })}${t('toothSelectedSuffix')}`
+ : t('toothAria', { fdi })
+ }
+ >
+ {glyph}
+
+ ) : (
+
+ {glyph}
+
+ )}
);
@@ -153,20 +207,12 @@ export function FdiToothChart({ selected, onToggle, disabled }: FdiToothChartPro
);
- return (
-
-
-
-
{t('toothChartTitle')}
-
- {t('toothChartHint')}
-
-
-
- {t('selectedLabel')} {selected.size === 0 ? t('selectedEmpty') : [...selected].sort().join(', ')}
-
-
+ const cardClass = compact
+ ? 'rounded-lg border border-border/60 bg-background-secondary/30 p-2 space-y-2'
+ : 'surface-card p-3 space-y-3';
+ const chartBody = (
+ <>
{t('upperArch')}
@@ -184,9 +230,8 @@ export function FdiToothChart({ selected, onToggle, disabled }: FdiToothChartPro
return (
{fdi}
@@ -204,9 +249,8 @@ export function FdiToothChart({ selected, onToggle, disabled }: FdiToothChartPro
return (
{fdi}
@@ -222,6 +266,36 @@ export function FdiToothChart({ selected, onToggle, disabled }: FdiToothChartPro
{t('lowerArch')}
+ >
+ );
+
+ return (
+
+
+
+
+ {compact ? t('toothChartTitleCompact') : t('toothChartTitle')}
+
+ {!compact && (
+
{t('toothChartHint')}
+ )}
+
+
+ {headerControl}
+
+ {t('selectedLabel')}{' '}
+ {selected.size === 0 ? t('selectedEmpty') : [...selected].sort().join(', ')}
+
+
+
+
+ {scale !== 1 ? (
+
+ {chartBody}
+
+ ) : (
+ chartBody
+ )}
);
}
diff --git a/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx b/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx
index 9ad7965..fe588ae 100644
--- a/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx
+++ b/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx
@@ -274,6 +274,16 @@ export function LabCasesDispatchPanel({
);
}
+ function toggleAttachmentInActiveLabCase(attachmentId: string, checked: boolean) {
+ if (!activeLabCase || sent) return;
+ const set = new Set(activeLabCase.attachmentIds);
+ if (checked) set.add(attachmentId);
+ else set.delete(attachmentId);
+ updateActiveLabCase({ attachmentIds: [...set] });
+ }
+
+ const activeDetailAttachments = activeDetail?.attachmentMetas ?? [];
+
const includedInActiveShipment = activeLabCase
? [activeDetail]
: [];
@@ -393,6 +403,26 @@ export function LabCasesDispatchPanel({
)}
+ {!sent && activeDetailAttachments.length > 0 ? (
+
+
+ {t('labShipmentAttachments')}
+
+
{t('labShipmentAttachmentsHint')}
+
+ {activeDetailAttachments.map((att) => (
+ toggleAttachmentInActiveLabCase(att.id, next)}
+ label={`${att.fileName} (${(att.sizeBytes / 1024).toFixed(1)} KB)`}
+ />
+ ))}
+
+
+ ) : null}
+
{activeLabCase.id ? (
Number.isNaN(n))) return null;
+ return { r, g, b };
+}
+
+function rgbaFromHex(hex: string, alpha: number): string {
+ const rgb = hexToRgb(hex);
+ if (!rgb) return `rgba(9, 169, 188, ${alpha})`;
+ return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${alpha})`;
}
function filledSilhouette(
@@ -39,8 +57,13 @@ function detailOnly(model: ToothPathModel, stroke: string, strokeW: number): Rea
return ;
}
-function renderClinicalFill(model: ToothPathModel, selected: boolean, gradientId: string): ReactNode {
- const stroke = selected ? 'var(--color-primary)' : '#1e293b';
+function renderClinicalFill(
+ model: ToothPathModel,
+ selected: boolean,
+ gradientId: string,
+ accentColor?: string,
+): ReactNode {
+ const stroke = selected ? (accentColor ?? 'var(--color-primary)') : '#1e293b';
const strokeW = selected ? 2.4 : 1.35;
const fill = `url(#${gradientId})`;
return (
@@ -50,6 +73,7 @@ function renderClinicalFill(model: ToothPathModel, selected: boolean, gradientId
>
);
}
+
/** FDI chart tooth: clinical gradient + roots only. */
export const ToothGlyph = memo(function ToothGlyph({
fdi,
@@ -60,9 +84,14 @@ export const ToothGlyph = memo(function ToothGlyph({
mirrored,
upsideDown,
className = 'w-8 h-[4.85rem]',
+ accentColor,
}: ToothGlyphProps) {
const model: ToothPathModel | null = getToothPathModel(fdi, kind, upper);
- const filter = selected ? 'drop-shadow(0 0 6px rgba(9, 169, 188, 0.65))' : undefined;
+ const filter = selected
+ ? accentColor
+ ? `drop-shadow(0 0 6px ${rgbaFromHex(accentColor, 0.75)})`
+ : 'drop-shadow(0 0 6px rgba(9, 169, 188, 0.65))'
+ : undefined;
if (!model) {
return (
@@ -74,7 +103,19 @@ export const ToothGlyph = memo(function ToothGlyph({
);
}
- const body = renderClinicalFill(model, selected, gradientId);
+ const body = renderClinicalFill(model, selected, gradientId, accentColor);
+
+ const selectedStops = accentColor
+ ? {
+ inner: '#ffffff',
+ mid: accentColor,
+ outer: accentColor,
+ }
+ : {
+ inner: '#cffafe',
+ mid: '#5eead4',
+ outer: '#0e7490',
+ };
return (
-
-
-
+
+
+
a.id),
sentAt: lc.sentAt ?? null,
sends: lc.sends ?? [],
};
@@ -296,6 +298,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
const [uploadBusyDetailId, setUploadBusyDetailId] = useState(null);
const [organizationSearch, setOrganizationSearch] = useState('');
const [recentOrganizationIds, setRecentOrganizationIds] = useState([]);
+ const [showWholeTreatmentPlan, setShowWholeTreatmentPlan] = useState(false);
const isDetailLocked = useCallback(
(detail: TreatmentDetailDraft) =>
@@ -388,6 +391,35 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
const selectedTeethSet = useMemo(() => new Set(activeDetail?.teeth ?? []), [activeDetail?.teeth]);
+ const wholePlanTeethSet = useMemo(() => {
+ const set = new Set();
+ for (const detail of details) {
+ for (const tooth of detail.teeth) set.add(tooth);
+ }
+ return set;
+ }, [details]);
+
+ const wholePlanToothColors = useMemo(() => {
+ const colors: Partial> = {};
+ for (let i = 0; i < details.length; i++) {
+ const detail = details[i];
+ const catalogIndex = treatmentCatalog.findIndex((e) => e.code === detail.treatmentType);
+ const color = treatmentTypeColor(detail.treatmentType, catalogIndex >= 0 ? catalogIndex : i);
+ for (const tooth of detail.teeth) {
+ if (!(tooth in colors)) colors[tooth] = color;
+ }
+ }
+ return colors;
+ }, [details, treatmentCatalog]);
+
+ const chartSelectedTeeth = showWholeTreatmentPlan ? wholePlanTeethSet : selectedTeethSet;
+ const chartToothColors = showWholeTreatmentPlan ? wholePlanToothColors : undefined;
+
+ // Reset whole-plan overview when switching details.
+ useEffect(() => {
+ setShowWholeTreatmentPlan(false);
+ }, [activeDetailId]);
+
// Sync active lab shipment when the selected treatment detail changes.
useEffect(() => {
const match = labCaseDrafts.find((lc) => lc.detailClientIds.includes(activeDetailId));
@@ -817,6 +849,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
};
})
.filter((row): row is { treatmentDetailId: string; tooth: string; prosthesisTypeCode: string } => row !== null),
+ attachmentIds: lc.attachmentIds,
}));
if (payload.length === 0) {
@@ -1040,9 +1073,24 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
1 ? (
+
+ setShowWholeTreatmentPlan(e.target.checked)}
+ className="rounded border-border"
+ />
+ {t('toothChartWholePlan')}
+
+ ) : undefined
+ }
onToggle={(fdi) => {
- if (!canEditTreatmentForDay || isDetailLocked(activeDetail)) return;
+ if (!canEditTreatmentForDay || isDetailLocked(activeDetail) || showWholeTreatmentPlan) return;
setDetails((prev) =>
prev.map((d) => {
if (d.clientId !== activeDetailId) return d;
diff --git a/frontend/src/lib/api/cases.ts b/frontend/src/lib/api/cases.ts
index a51a076..ff8dcab 100644
--- a/frontend/src/lib/api/cases.ts
+++ b/frontend/src/lib/api/cases.ts
@@ -33,4 +33,12 @@ export const casesApi = {
const response = await apiClient.patch(`/cases/${caseId}/tasks/${taskId}`, { isImportant });
return response.data;
},
+
+ getAttachmentFileBlob: async (caseId: string, attachmentId: string): Promise => {
+ const response = await apiClient.get(`/cases/${caseId}/attachments/${attachmentId}/file`, {
+ responseType: 'blob',
+ timeout: 120_000,
+ });
+ return response.data;
+ },
};
diff --git a/frontend/src/types/cases.ts b/frontend/src/types/cases.ts
index 954be9b..4d6d08d 100644
--- a/frontend/src/types/cases.ts
+++ b/frontend/src/types/cases.ts
@@ -66,6 +66,14 @@ export interface LabCaseComment {
showVisibilityStatus?: boolean;
}
+export interface LabCaseAttachmentMeta {
+ id: string;
+ fileName: string;
+ mimeType: string;
+ sizeBytes: number;
+ createdAt: string;
+}
+
export interface LabCaseDetail {
id: string;
sentAt: string | null;
@@ -84,6 +92,12 @@ export interface LabCaseDetail {
teeth: string[];
comment: string | null;
}>;
+ toothProsthesis: Array<{
+ treatmentDetailId: string;
+ tooth: string;
+ prosthesisTypeCode: string;
+ }>;
+ attachments: LabCaseAttachmentMeta[];
sends: Array<{
organizationId: string;
organizationName: string;
diff --git a/frontend/src/types/treatment.ts b/frontend/src/types/treatment.ts
index bf6c46f..e47c51d 100644
--- a/frontend/src/types/treatment.ts
+++ b/frontend/src/types/treatment.ts
@@ -103,6 +103,7 @@ export interface PastLabCase {
prosthesisTypeCode: string;
}>;
sends?: LabCaseSendInfo[];
+ attachments?: TreatmentAttachmentMeta[];
}
export interface PastTreatment {
@@ -144,6 +145,7 @@ export interface LabCaseDraft {
destinationOrganizationId: string | null;
detailClientIds: string[];
toothProsthesis: LabCaseToothProsthesisDraft[];
+ attachmentIds: string[];
sentAt?: string | null;
sends?: LabCaseSendInfo[];
}
@@ -170,6 +172,7 @@ export interface SaveLabCasePayload {
tooth: string;
prosthesisTypeCode: string;
}>;
+ attachmentIds?: string[];
}
export interface SaveTreatmentPayload {
@@ -192,4 +195,5 @@ export interface LabCaseResponse {
}>;
sends: LabCaseSendInfo[];
toothProsthesis?: LabCaseToothProsthesisDraft[];
+ attachments?: TreatmentAttachmentMeta[];
}
--
2.53.0.windows.1
From 8d334833ff77355b86fe23a5b9aab50103f34129 Mon Sep 17 00:00:00 2001
From: Admin
Date: Wed, 8 Jul 2026 02:53:47 +0330
Subject: [PATCH 17/17] improvement: all demo bugs fixed. give me more baby.
---
.../migration.sql | 13 +
backend/prisma/schema.prisma | 3 +-
backend/src/modules/cases/cases.controller.ts | 13 +-
backend/src/modules/cases/cases.service.ts | 48 +--
backend/src/modules/cases/dto/cases.dto.ts | 2 +-
backend/src/modules/tasks/dto/tasks.dto.ts | 11 +-
backend/src/modules/tasks/tasks.service.ts | 23 +-
frontend/messages/en.json | 16 +-
frontend/messages/fa.json | 15 +-
frontend/messages/nl.json | 15 +-
.../app/[locale]/(dashboard)/cases/page.tsx | 322 ++++--------------
.../app/[locale]/(dashboard)/tasks/page.tsx | 49 +--
.../src/components/ui/lab/CaseDetailPanel.tsx | 245 +++++++++++++
.../components/ui/lab/CaseToothChartPanel.tsx | 6 +-
.../ui/lab/LabCaseAttachmentPreview.tsx | 2 +-
.../ui/lab/LabCaseAttachmentsDialog.tsx | 211 ++++++++++++
.../ui/lab/LabCaseCommentsPanel.tsx | 97 ++++--
.../src/components/ui/lab/caseDetailUtils.ts | 40 +++
.../ConnectionCaseHistoryContent.tsx | 297 ++++------------
.../ui/treatment/LabCasesDispatchPanel.tsx | 13 +-
.../ui/treatment/TreatmentWorkspace.tsx | 42 ++-
frontend/src/lib/api/cases.ts | 8 +-
frontend/src/types/cases.ts | 11 +-
23 files changed, 876 insertions(+), 626 deletions(-)
create mode 100644 backend/prisma/migrations/20260708120000_case_level_important/migration.sql
create mode 100644 frontend/src/components/ui/lab/CaseDetailPanel.tsx
create mode 100644 frontend/src/components/ui/lab/LabCaseAttachmentsDialog.tsx
create mode 100644 frontend/src/components/ui/lab/caseDetailUtils.ts
diff --git a/backend/prisma/migrations/20260708120000_case_level_important/migration.sql b/backend/prisma/migrations/20260708120000_case_level_important/migration.sql
new file mode 100644
index 0000000..ee816ed
--- /dev/null
+++ b/backend/prisma/migrations/20260708120000_case_level_important/migration.sql
@@ -0,0 +1,13 @@
+-- Move the "important" flag from individual tasks to the case as a whole.
+ALTER TABLE "lab_cases" ADD COLUMN "isImportant" BOOLEAN NOT NULL DEFAULT false;
+
+-- Carry over existing importance: a case is important if any of its tasks were.
+UPDATE "lab_cases" lc
+SET "isImportant" = true
+WHERE EXISTS (
+ SELECT 1 FROM "lab_case_tasks" t
+ WHERE t."labCaseId" = lc."id" AND t."isImportant" = true
+);
+
+DROP INDEX IF EXISTS "lab_case_tasks_labCaseId_isImportant_idx";
+ALTER TABLE "lab_case_tasks" DROP COLUMN "isImportant";
diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma
index 3e7c2df..1719f11 100644
--- a/backend/prisma/schema.prisma
+++ b/backend/prisma/schema.prisma
@@ -195,6 +195,7 @@ model LabCase {
sortOrder Int
destinationOrganizationId String?
sentAt DateTime?
+ isImportant Boolean @default(false)
treatment Treatment @relation(fields: [treatmentId], references: [id], onDelete: Cascade)
details LabCaseDetail[]
@@ -333,7 +334,6 @@ model LabCaseTask {
workflowStepCode String
stepOrder Int
stepLabel String
- isImportant Boolean @default(false)
status LabTaskStatus @default(IN_PROGRESS)
lastStatusChangedByUserId String?
lastStatusChangedAt DateTime?
@@ -348,7 +348,6 @@ model LabCaseTask {
@@unique([labCaseId, treatmentDetailId, prosthesisTypeCode, stepOrder])
@@index([labCaseId, status])
- @@index([labCaseId, isImportant])
@@map("lab_case_tasks")
}
diff --git a/backend/src/modules/cases/cases.controller.ts b/backend/src/modules/cases/cases.controller.ts
index 8b319d6..d54a7b1 100644
--- a/backend/src/modules/cases/cases.controller.ts
+++ b/backend/src/modules/cases/cases.controller.ts
@@ -14,7 +14,7 @@ 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';
+import { ListLabCasesDto, UpdateLabCaseImportantDto } from './dto/cases.dto';
@ApiTags('cases')
@ApiBearerAuth('JWT-auth')
@@ -64,15 +64,14 @@ export class CasesController {
file.stream.pipe(res);
}
- @Patch(':id/tasks/:taskId')
- @ApiOperation({ summary: 'Toggle task important flag' })
- updateTask(
+ @Patch(':id/important')
+ @ApiOperation({ summary: 'Toggle the important flag for a whole case' })
+ setCaseImportant(
@Param('id') id: string,
- @Param('taskId') taskId: string,
- @Body() dto: UpdateLabCaseTaskDto,
+ @Body() dto: UpdateLabCaseImportantDto,
@Req() req,
) {
const organizationId = this.casesService.getOrganizationIdFromUser(req.user);
- return this.casesService.updateTask(id, taskId, dto, organizationId, req.user.id, req.user.language);
+ return this.casesService.setCaseImportant(id, dto, organizationId, req.user.id, req.user.language);
}
}
diff --git a/backend/src/modules/cases/cases.service.ts b/backend/src/modules/cases/cases.service.ts
index d7fe45c..5d7c482 100644
--- a/backend/src/modules/cases/cases.service.ts
+++ b/backend/src/modules/cases/cases.service.ts
@@ -14,7 +14,7 @@ import {
} from '../catalog/catalog-label.service';
import { TreatmentCatalogService } from '../treatment-catalog/treatment-catalog.service';
import { normalizeTeeth } from '../treatments/treatment.utils';
-import { ListLabCasesDto, UpdateLabCaseTaskDto } from './dto/cases.dto';
+import { ListLabCasesDto, UpdateLabCaseImportantDto } from './dto/cases.dto';
import { normalizeTaskTeeth } from './lab-case-task.util';
const labCaseListInclude = {
@@ -335,51 +335,39 @@ export class CasesService {
};
}
- async updateTask(
+ async setCaseImportant(
labCaseId: string,
- taskId: string,
- dto: UpdateLabCaseTaskDto,
+ dto: UpdateLabCaseImportantDto,
labOrganizationId: string,
actorUserId: string,
localeInput?: string | null,
) {
await this.assertCanEditCases(actorUserId, labOrganizationId);
- const task = await this.prisma.labCaseTask.findFirst({
+ const existing = await this.prisma.labCase.findFirst({
where: {
- id: taskId,
- labCaseId,
- labCase: {
- sentAt: { not: null },
- sends: { some: { organizationId: labOrganizationId } },
- },
+ id: labCaseId,
+ sentAt: { not: null },
+ sends: { some: { organizationId: labOrganizationId } },
},
+ select: { id: true },
});
- if (!task) {
- throw new NotFoundException('Task not found');
+ if (!existing) {
+ throw new NotFoundException('Case not found');
}
- const updated = await this.prisma.labCaseTask.update({
- where: { id: taskId },
+ await this.prisma.labCase.update({
+ where: { id: labCaseId },
data: { isImportant: dto.isImportant },
- include: {
- lastStatusChangedBy: { select: { id: true, name: true } },
- statusEvents: {
- orderBy: { changedAt: 'asc' },
- include: { changedBy: { select: { id: true, name: true } } },
- },
- },
});
- const locale = normalizeCatalogLocale(localeInput);
- const prosthesisLabels = await this.catalogLabels.resolveLabels(
- CatalogEntityKind.PROSTHESIS_TYPE,
- [updated.prosthesisTypeCode],
- locale,
- );
+ const labCase = await this.prisma.labCase.findFirstOrThrow({
+ where: { id: labCaseId },
+ include: labCaseListInclude,
+ });
- return { success: true, data: this.mapTask(updated, prosthesisLabels) };
+ return { success: true, data: await this.mapLabCaseDetail(labCase, localeInput) };
}
private buildListWhere(
@@ -499,6 +487,7 @@ export class CasesService {
return {
id: lc.id,
sentAt: lc.sentAt?.toISOString() ?? null,
+ isImportant: lc.isImportant,
clinic: lc.treatment.organization,
patient: lc.treatment.patient,
appointmentStartAt: lc.treatment.appointment?.startAt.toISOString() ?? null,
@@ -585,7 +574,6 @@ export class CasesService {
stepOrder: task.stepOrder,
stepLabel: task.stepLabel,
status: task.status,
- isImportant: task.isImportant,
createdAt: task.createdAt.toISOString(),
lastStatusChangedAt: task.lastStatusChangedAt?.toISOString() ?? null,
lastStatusChangedBy: task.lastStatusChangedBy
diff --git a/backend/src/modules/cases/dto/cases.dto.ts b/backend/src/modules/cases/dto/cases.dto.ts
index 6e567fd..afa969b 100644
--- a/backend/src/modules/cases/dto/cases.dto.ts
+++ b/backend/src/modules/cases/dto/cases.dto.ts
@@ -1,7 +1,7 @@
import { Transform } from 'class-transformer';
import { IsBoolean, IsDateString, IsInt, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator';
-export class UpdateLabCaseTaskDto {
+export class UpdateLabCaseImportantDto {
@IsBoolean()
isImportant: boolean;
}
diff --git a/backend/src/modules/tasks/dto/tasks.dto.ts b/backend/src/modules/tasks/dto/tasks.dto.ts
index 9898378..c1ffe75 100644
--- a/backend/src/modules/tasks/dto/tasks.dto.ts
+++ b/backend/src/modules/tasks/dto/tasks.dto.ts
@@ -25,7 +25,14 @@ export class UpdateLabTaskDto {
status: LabTaskStatus;
}
-export type TaskSortField = 'date' | 'status' | 'clinic' | 'patient' | 'important';
+export type TaskSortField =
+ | 'date'
+ | 'status'
+ | 'clinic'
+ | 'patient'
+ | 'important'
+ | 'prosthesis'
+ | 'taskType';
export class ListLabTasksDto {
@IsOptional()
@@ -59,7 +66,7 @@ export class ListLabTasksDto {
sentTo?: string;
@IsOptional()
- @IsIn(['date', 'status', 'clinic', 'patient', 'important'])
+ @IsIn(['date', 'status', 'clinic', 'patient', 'important', 'prosthesis', 'taskType'])
sortBy?: TaskSortField;
@IsOptional()
diff --git a/backend/src/modules/tasks/tasks.service.ts b/backend/src/modules/tasks/tasks.service.ts
index 9c15b51..e3ded96 100644
--- a/backend/src/modules/tasks/tasks.service.ts
+++ b/backend/src/modules/tasks/tasks.service.ts
@@ -188,9 +188,9 @@ export class TasksService {
? { treatment: { organizationId: query.clinicOrganizationId } }
: {}),
...(query.q?.trim() ? { treatment: this.buildSearchWhere(query.q.trim()) } : {}),
+ ...(query.important !== undefined ? { isImportant: query.important } : {}),
},
...(status !== undefined ? { status } : {}),
- ...(query.important !== undefined ? { isImportant: query.important } : {}),
};
}
@@ -229,15 +229,24 @@ export class TasksService {
{ id: 'asc' },
];
case 'important':
- return [{ isImportant: dir }, { createdAt: 'desc' }, { id: 'asc' }];
+ return [{ labCase: { isImportant: dir } }, { createdAt: 'desc' }, { id: 'asc' }];
+ case 'prosthesis':
+ return [{ prosthesisTypeCode: dir }, { createdAt: 'desc' }, { id: 'asc' }];
+ case 'taskType':
+ return [
+ { workflowStepCode: dir },
+ { stepOrder: 'asc' },
+ { createdAt: 'desc' },
+ { id: 'asc' },
+ ];
case 'date':
default:
+ // date / caseId / taskId / stepId — newest first by default.
return [
{ labCase: { sentAt: dir } },
- { labCaseId: 'asc' },
- { treatmentDetailId: 'asc' },
- { stepOrder: 'asc' },
- { id: 'asc' },
+ { labCaseId: dir },
+ { id: dir },
+ { stepOrder: dir },
];
}
}
@@ -259,7 +268,7 @@ export class TasksService {
stepOrder: task.stepOrder,
stepLabel: task.stepLabel,
status: task.status,
- isImportant: task.isImportant,
+ isImportant: task.labCase.isImportant,
lastStatusChangedAt: task.lastStatusChangedAt?.toISOString() ?? null,
lastStatusChangedBy: task.lastStatusChangedBy
? { id: task.lastStatusChangedBy.id, name: task.lastStatusChangedBy.name }
diff --git a/frontend/messages/en.json b/frontend/messages/en.json
index 532a3e0..2ee0e28 100644
--- a/frontend/messages/en.json
+++ b/frontend/messages/en.json
@@ -335,6 +335,7 @@
"statusInProgress": "In progress",
"statusCompleted": "Completed",
"importantLabel": "Important",
+ "markCaseImportant": "Mark case as important",
"markImportant": "Mark as important",
"lastUpdatedBy": "Updated by {name}",
"lastUpdatedUnknown": "Not started yet",
@@ -353,7 +354,13 @@
"patientMobile": "Mobile",
"showComments": "Comments",
"commentsCount": "Comments ({count})",
- "latestAttachment": "Latest file",
+ "viewAttachments": "View all attachments",
+ "attachmentsDialogTitle": "Case attachments",
+ "attachmentsDialogSubtitle": "Preview and download files shared with this case.",
+ "noAttachments": "No attachments were shared with this case.",
+ "downloadAttachment": "Download",
+ "downloadAllAttachments": "Download all",
+ "attachmentPreviewUnavailable": "Preview unavailable",
"prevPage": "Previous",
"nextPage": "Next",
"pageSummary": "Page {page} of {totalPages} ({total} cases)",
@@ -391,6 +398,8 @@
"sortClinic": "Clinic",
"sortPatient": "Patient",
"sortImportant": "Important",
+ "sortProsthesis": "Prosthesis type",
+ "sortTaskType": "Task type",
"sortDirection": "Sort direction",
"clearFilters": "Clear filters",
"commentsButton": "Comments",
@@ -413,7 +422,10 @@
"clinicAuthor": "Clinic",
"errorLoad": "Failed to load comments.",
"errorPost": "Failed to post comment.",
- "errorToggle": "Failed to update comment visibility."
+ "errorToggle": "Failed to update comment visibility.",
+ "send": "Send comment",
+ "composerVisible": "Visible to clinic",
+ "composerHidden": "Hidden from clinic"
},
"appointments": {
"title": "Appointments",
diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json
index d8e53b3..53b41c7 100644
--- a/frontend/messages/fa.json
+++ b/frontend/messages/fa.json
@@ -335,6 +335,7 @@
"statusInProgress": "در حال انجام",
"statusCompleted": "انجام شده",
"importantLabel": "مهم",
+ "markCaseImportant": "علامتگذاری پرونده بهعنوان مهم",
"markImportant": "علامتگذاری به عنوان مهم",
"lastUpdatedBy": "بهروزرسانی توسط {name}",
"lastUpdatedUnknown": "هنوز شروع نشده",
@@ -354,6 +355,13 @@
"showComments": "نظرات",
"commentsCount": "نظرات ({count})",
"latestAttachment": "آخرین فایل",
+ "viewAttachments": "مشاهده همه پیوستها",
+ "attachmentsDialogTitle": "پیوستهای پرونده",
+ "attachmentsDialogSubtitle": "پیشنمایش و دانلود فایلهای بهاشتراکگذاشتهشده با این پرونده.",
+ "noAttachments": "هیچ پیوستی با این پرونده بهاشتراک گذاشته نشده است.",
+ "downloadAttachment": "دانلود",
+ "downloadAllAttachments": "دانلود همه",
+ "attachmentPreviewUnavailable": "پیشنمایش در دسترس نیست",
"prevPage": "قبلی",
"nextPage": "بعدی",
"pageSummary": "صفحه {page} از {totalPages} ({total} پرونده)",
@@ -391,6 +399,8 @@
"sortClinic": "کلینیک",
"sortPatient": "بیمار",
"sortImportant": "مهم",
+ "sortProsthesis": "نوع پروتز",
+ "sortTaskType": "نوع کار",
"sortDirection": "جهت مرتبسازی",
"clearFilters": "پاک کردن فیلترها",
"commentsButton": "نظرات",
@@ -413,7 +423,10 @@
"clinicAuthor": "کلینیک",
"errorLoad": "بارگذاری نظرات ناموفق بود.",
"errorPost": "ثبت نظر ناموفق بود.",
- "errorToggle": "بهروزرسانی وضعیت نمایش نظر ناموفق بود."
+ "errorToggle": "بهروزرسانی وضعیت نمایش نظر ناموفق بود.",
+ "send": "ارسال نظر",
+ "composerVisible": "قابل مشاهده برای کلینیک",
+ "composerHidden": "پنهان از کلینیک"
},
"appointments": {
"title": "نوبتها",
diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json
index 786520f..fb34543 100644
--- a/frontend/messages/nl.json
+++ b/frontend/messages/nl.json
@@ -335,6 +335,7 @@
"statusInProgress": "Bezig",
"statusCompleted": "Voltooid",
"importantLabel": "Belangrijk",
+ "markCaseImportant": "Zaak als belangrijk markeren",
"markImportant": "Markeren als belangrijk",
"lastUpdatedBy": "Bijgewerkt door {name}",
"lastUpdatedUnknown": "Nog niet gestart",
@@ -354,6 +355,13 @@
"showComments": "Opmerkingen",
"commentsCount": "Opmerkingen ({count})",
"latestAttachment": "Laatste bestand",
+ "viewAttachments": "Alle bijlagen bekijken",
+ "attachmentsDialogTitle": "Zaakbijlagen",
+ "attachmentsDialogSubtitle": "Bekijk en download bestanden die met deze zaak zijn gedeeld.",
+ "noAttachments": "Er zijn geen bijlagen met deze zaak gedeeld.",
+ "downloadAttachment": "Downloaden",
+ "downloadAllAttachments": "Alles downloaden",
+ "attachmentPreviewUnavailable": "Voorbeeld niet beschikbaar",
"prevPage": "Vorige",
"nextPage": "Volgende",
"pageSummary": "Pagina {page} van {totalPages} ({total} dossiers)",
@@ -391,6 +399,8 @@
"sortClinic": "Kliniek",
"sortPatient": "Patiënt",
"sortImportant": "Belangrijk",
+ "sortProsthesis": "Prothesetype",
+ "sortTaskType": "Taaktype",
"sortDirection": "Sorteerrichting",
"clearFilters": "Filters wissen",
"commentsButton": "Opmerkingen",
@@ -413,7 +423,10 @@
"clinicAuthor": "Kliniek",
"errorLoad": "Opmerkingen laden mislukt.",
"errorPost": "Opmerking plaatsen mislukt.",
- "errorToggle": "Zichtbaarheid bijwerken mislukt."
+ "errorToggle": "Zichtbaarheid bijwerken mislukt.",
+ "send": "Opmerking versturen",
+ "composerVisible": "Zichtbaar voor kliniek",
+ "composerHidden": "Verborgen voor kliniek"
},
"appointments": {
"title": "Afspraken",
diff --git a/frontend/src/app/[locale]/(dashboard)/cases/page.tsx b/frontend/src/app/[locale]/(dashboard)/cases/page.tsx
index c274887..da9bcf5 100644
--- a/frontend/src/app/[locale]/(dashboard)/cases/page.tsx
+++ b/frontend/src/app/[locale]/(dashboard)/cases/page.tsx
@@ -3,17 +3,17 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { useTranslations } from 'next-intl';
-import { MessageSquare } from 'lucide-react';
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 { canEditCases, canEditTasks } from '@/components/shared/permissions';
-import { Badge } from '@/components/ui/shared/Badge';
+import { CaseDetailPanel, CaseTaskProgressBar } from '@/components/ui/lab/CaseDetailPanel';
import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel';
-import { CaseToothChartPanel } from '@/components/ui/lab/CaseToothChartPanel';
-import { LabCaseAttachmentPreview } from '@/components/ui/lab/LabCaseAttachmentPreview';
-import { labTaskStatusVariant } from '@/components/ui/lab/labTaskStatusDisplay';
+import {
+ formatCaseDateTime,
+ formatPatientName,
+} from '@/components/ui/lab/caseDetailUtils';
import { casesApi } from '@/lib/api/cases';
import { tasksApi } from '@/lib/api/tasks';
import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
@@ -29,47 +29,11 @@ import type {
LabTaskStatus,
PaginatedLabCases,
} from '@/types/cases';
-import {
- formatToothList,
- prosthesisTypeBadgeStyle,
-} from '@/components/ui/treatment/prosthesisTypeDisplay';
const PAGE_SIZE = 20;
-function formatPatientName(patient: { firstName: string; lastName: string }) {
- return `${patient.firstName} ${patient.lastName}`.trim();
-}
-
-function formatDateTime(value: string | null, locale: string) {
- if (!value) return '—';
- return new Intl.DateTimeFormat(locale, {
- dateStyle: 'medium',
- timeStyle: 'short',
- }).format(new Date(value));
-}
-
-function TaskProgressBar({ completed, total }: { completed: number; total: number }) {
- const pct = total > 0 ? Math.round((completed / total) * 100) : 0;
-
- return (
-
-
- {completed}/{total}
- {pct}%
-
-
-
- );
-}
-
export default function CasesPage() {
const t = useTranslations('cases');
- const tTreatment = useTranslations('treatment');
const tCommon = useTranslations('common');
const { currentOrganization, user } = useAuth();
const toast = useToast();
@@ -99,7 +63,7 @@ export default function CasesPage() {
const [selectedCase, setSelectedCase] = useState(null);
const [loadingList, setLoadingList] = useState(false);
const [loadingDetail, setLoadingDetail] = useState(false);
- const [updatingTaskId, setUpdatingTaskId] = useState(null);
+ const [updatingImportant, setUpdatingImportant] = useState(false);
const [commentCount, setCommentCount] = useState(0);
const canEdit = canEditCases(currentOrganization);
@@ -152,17 +116,23 @@ export default function CasesPage() {
}
};
- const loadDetail = async (caseId: string) => {
- setLoadingDetail(true);
+ const loadDetail = async (caseId: string, options?: { silent?: boolean }) => {
+ if (!options?.silent) {
+ 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);
+ if (!options?.silent) {
+ setSelectedCase(null);
+ }
} finally {
- setLoadingDetail(false);
+ if (!options?.silent) {
+ setLoadingDetail(false);
+ }
}
};
@@ -217,34 +187,6 @@ export default function CasesPage() {
[],
);
- const latestCaseAttachment = useMemo(() => {
- if (!selectedCase?.attachments.length) return null;
- return [...selectedCase.attachments].sort(
- (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
- )[0];
- }, [selectedCase?.attachments]);
-
- const caseProsthesisRows = useMemo(() => {
- if (!selectedCase) return [];
- if (selectedCase.toothProsthesis.length > 0) {
- const byCode = new Map();
- for (const row of selectedCase.toothProsthesis) {
- const key = row.prosthesisTypeCode;
- const teeth = byCode.get(key) ?? [];
- if (!teeth.includes(row.tooth)) teeth.push(row.tooth);
- byCode.set(key, teeth);
- }
- return [...byCode.entries()].map(([prosthesisTypeCode, teeth]) => ({
- prosthesisTypeCode,
- teeth,
- }));
- }
- return selectedCase.tasksByTooth.map((g) => ({
- prosthesisTypeCode: g.prosthesisTypeCode,
- teeth: g.teeth,
- }));
- }, [selectedCase]);
-
function clearFilters() {
setSearch('');
setClinicId('');
@@ -254,18 +196,22 @@ export default function CasesPage() {
setPage(1);
}
- async function handleImportantToggle(taskId: string, isImportant: boolean) {
- if (!selectedCaseId || !canEdit) return;
+ async function handleCaseImportantToggle(isImportant: boolean) {
+ if (!selectedCaseId || !canEdit || !selectedCase) return;
- setUpdatingTaskId(taskId);
+ const previousCase = selectedCase;
+ setSelectedCase({ ...selectedCase, isImportant });
+
+ setUpdatingImportant(true);
toast.setError('');
try {
- await casesApi.setTaskImportant(selectedCaseId, taskId, isImportant);
- await loadDetail(selectedCaseId);
+ const response = await casesApi.setCaseImportant(selectedCaseId, isImportant);
+ setSelectedCase(response.data);
} catch (error: unknown) {
+ setSelectedCase(previousCase);
toast.showError(formatApiErrorMessage(error, t('errorUpdateTask')));
} finally {
- setUpdatingTaskId(null);
+ setUpdatingImportant(false);
}
}
@@ -391,13 +337,13 @@ export default function CasesPage() {
{item.clinic.name}
- {formatDateTime(item.sentAt, locale)}
+ {formatCaseDateTime(item.sentAt, locale)}
{item.treatmentTypes.map(treatmentLabel).join(', ')}
-
@@ -445,181 +391,53 @@ export default function CasesPage() {
) : loadingDetail || !selectedCase ? (
{tCommon('loading')}
) : (
-
-
-
-
- {formatPatientName(selectedCase.patient)}
-
-
-
- {commentCount > 0
- ? t('commentsCount', { count: commentCount })
- : t('showComments')}
-
-
-
- {t('patientMobile')}: {selectedCase.patient.mobile}
-
+ void handleCaseImportantToggle(checked)}
+ headerMetaLines={
{t('fromClinic', { name: selectedCase.clinic.name })}
-
- {t('sentAt', { date: formatDateTime(selectedCase.sentAt, locale) })}
-
-
-
- {t('taskProgressLabel', {
- completed: selectedCase.taskProgress.completed,
- total: selectedCase.taskProgress.total,
- })}
-
-
-
-
-
-
-
- {latestCaseAttachment && selectedCaseId ? (
-
-
{t('latestAttachment')}
-
- ) : null}
-
-
- {selectedCase.details.length > 0 && (
-
-
{t('treatmentDetails')}
-
-
- )}
-
-
-
{t('tasksByTooth')}
- {selectedCase.tasksByTooth.length === 0 ? (
-
{t('noTasks')}
- ) : (
- selectedCase.tasksByTooth.map((group, groupIndex) => (
-
-
-
- {group.prosthesisTypeLabel}
-
-
- {t('toothGroupTitle', {
- teeth: formatToothList(group.teeth),
- prosthesis: group.prosthesisTypeLabel,
- })}
-
-
-
-
- ))
- )}
-
-
- {selectedCaseId ? (
-
- ) : null}
-
+
+ ) : null
+ }
+ />
)}
diff --git a/frontend/src/app/[locale]/(dashboard)/tasks/page.tsx b/frontend/src/app/[locale]/(dashboard)/tasks/page.tsx
index 64e9d36..91c47bf 100644
--- a/frontend/src/app/[locale]/(dashboard)/tasks/page.tsx
+++ b/frontend/src/app/[locale]/(dashboard)/tasks/page.tsx
@@ -51,13 +51,11 @@ export default function TasksPage() {
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [updatingTaskId, setUpdatingTaskId] = useState(null);
- const [expandedCommentsCaseId, setExpandedCommentsCaseId] = useState(null);
+ const [expandedCommentsTaskId, setExpandedCommentsTaskId] = useState(null);
const [search, setSearch] = useState('');
const [clinicId, setClinicId] = useState('');
- const [statusFilter, setStatusFilter] = useState<'' | LabTaskStatus>('');
- const [showCompleted, setShowCompleted] = useState(false);
- const [importantOnly, setImportantOnly] = useState(false);
+ const [statusFilter, setStatusFilter] = useState<'' | LabTaskStatus>('IN_PROGRESS');
const [sentFrom, setSentFrom] = useState('');
const [sentTo, setSentTo] = useState('');
const [sortBy, setSortBy] = useState('date');
@@ -87,18 +85,11 @@ export default function TasksPage() {
};
if (search.trim()) params.q = search.trim();
if (clinicId) params.clinicOrganizationId = clinicId;
- if (statusFilter) {
- params.status = statusFilter;
- } else if (showCompleted) {
- params.completed = undefined;
- } else {
- params.completed = false;
- }
- if (importantOnly) params.important = true;
+ if (statusFilter) params.status = statusFilter;
if (sentFrom) params.sentFrom = sentFrom;
if (sentTo) params.sentTo = sentTo;
return params;
- }, [page, search, clinicId, statusFilter, showCompleted, importantOnly, sentFrom, sentTo, sortBy, sortDir]);
+ }, [page, search, clinicId, statusFilter, sentFrom, sentTo, sortBy, sortDir]);
const clinicOptions = useMemo(() => {
const map = new Map();
@@ -228,10 +219,10 @@ export default function TasksPage() {
className={`${filterSelectClass} min-w-0 flex-1`}
>
{t('sortDate')}
- {t('sortStatus')}
{t('sortClinic')}
{t('sortPatient')}
- {t('sortImportant')}
+ {t('sortProsthesis')}
+ {t('sortTaskType')}
-
-
- {
- setShowCompleted(e.target.checked);
- setPage(1);
- }}
- />
- {t('showCompleted')}
-
-
- {
- setImportantOnly(e.target.checked);
- setPage(1);
- }}
- />
- {t('importantOnly')}
-
-
@@ -279,7 +246,7 @@ export default function TasksPage() {
) : (
{tasks.map((task, index) => {
- const commentsOpen = expandedCommentsCaseId === task.labCaseId;
+ const commentsOpen = expandedCommentsTaskId === task.id;
return (
@@ -343,7 +310,7 @@ export default function TasksPage() {
- setExpandedCommentsCaseId(commentsOpen ? null : task.labCaseId)
+ setExpandedCommentsTaskId(commentsOpen ? null : task.id)
}
className={`p-1.5 rounded border ${
commentsOpen
diff --git a/frontend/src/components/ui/lab/CaseDetailPanel.tsx b/frontend/src/components/ui/lab/CaseDetailPanel.tsx
new file mode 100644
index 0000000..d2fe4b5
--- /dev/null
+++ b/frontend/src/components/ui/lab/CaseDetailPanel.tsx
@@ -0,0 +1,245 @@
+'use client';
+
+import { useMemo, useState, type ReactNode } from 'react';
+import { useTranslations } from 'next-intl';
+import { MessageSquare } from 'lucide-react';
+import { Badge } from '@/components/ui/shared/Badge';
+import { Button } from '@/components/ui/shared/Button';
+import { Checkbox } from '@/components/ui/shared/Checkbox';
+import { CaseToothChartPanel } from '@/components/ui/lab/CaseToothChartPanel';
+import { LabCaseAttachmentPreview } from '@/components/ui/lab/LabCaseAttachmentPreview';
+import { LabCaseAttachmentsDialog } from '@/components/ui/lab/LabCaseAttachmentsDialog';
+import { labTaskStatusVariant } from '@/components/ui/lab/labTaskStatusDisplay';
+import {
+ formatToothList,
+ prosthesisTypeBadgeStyle,
+} from '@/components/ui/treatment/prosthesisTypeDisplay';
+import {
+ buildCaseProsthesisRows,
+ formatCaseDateTime,
+ formatPatientName,
+ latestCaseAttachment,
+} from '@/components/ui/lab/caseDetailUtils';
+import type { LabCaseDetail, LabTaskStatus } from '@/types/cases';
+
+function CaseTaskProgressBar({ completed, total }: { completed: number; total: number }) {
+ const pct = total > 0 ? Math.round((completed / total) * 100) : 0;
+
+ return (
+
+
+
+ {completed}/{total}
+
+ {pct}%
+
+
+
+ );
+}
+
+export interface CaseDetailPanelProps {
+ labCase: LabCaseDetail;
+ locale: string;
+ treatmentLabel: (type: string) => string;
+ statusOptions: { value: LabTaskStatus; label: string }[];
+ loadAttachmentBlob: (caseId: string, attachmentId: string) => Promise;
+ /** Extra lines below patient mobile (e.g. connection-specific clinic/lab line). */
+ headerMetaLines?: ReactNode;
+ showCommentsButton?: boolean;
+ commentCount?: number;
+ onCommentsClick?: () => void;
+ canEditImportant?: boolean;
+ updatingImportant?: boolean;
+ onImportantChange?: (checked: boolean) => void;
+ commentsSection?: ReactNode;
+}
+
+export function CaseDetailPanel({
+ labCase,
+ locale,
+ treatmentLabel,
+ statusOptions,
+ loadAttachmentBlob,
+ headerMetaLines,
+ showCommentsButton = false,
+ commentCount = 0,
+ onCommentsClick,
+ canEditImportant = false,
+ updatingImportant = false,
+ onImportantChange,
+ commentsSection,
+}: CaseDetailPanelProps) {
+ const t = useTranslations('cases');
+ const [attachmentsDialogOpen, setAttachmentsDialogOpen] = useState(false);
+
+ const prosthesisRows = useMemo(() => buildCaseProsthesisRows(labCase), [labCase]);
+ const previewAttachment = useMemo(() => latestCaseAttachment(labCase), [labCase]);
+
+ return (
+
+
+
+
+ {formatPatientName(labCase.patient)}
+
+ {!canEditImportant && labCase.isImportant ? (
+
+ {t('importantLabel')}
+
+ ) : null}
+
+ {t('patientMobile')}: {labCase.patient.mobile}
+
+ {headerMetaLines}
+
+ {t('sentAt', { date: formatCaseDateTime(labCase.sentAt, locale) })}
+
+
+
+ {t('taskProgressLabel', {
+ completed: labCase.taskProgress.completed,
+ total: labCase.taskProgress.total,
+ })}
+
+
+
+
+
+
+ {showCommentsButton && onCommentsClick ? (
+
+
+ {commentCount > 0
+ ? t('commentsCount', { count: commentCount })
+ : t('showComments')}
+
+ ) : null}
+ {canEditImportant ? (
+ onImportantChange?.(checked)}
+ />
+ ) : null}
+ {previewAttachment && labCase.attachments.length > 0 ? (
+ setAttachmentsDialogOpen(true)}
+ className="aspect-square w-32 cursor-pointer rounded-[var(--radius-md)] border border-border/60 overflow-hidden transition-colors hover:border-primary/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
+ title={previewAttachment.fileName}
+ aria-label={t('viewAttachments')}
+ >
+
+
+ ) : null}
+
+
+
+
+
+ {labCase.details.length > 0 ? (
+
+
{t('treatmentDetails')}
+
+
+ ) : null}
+
+
+
{t('tasksByTooth')}
+ {labCase.tasksByTooth.length === 0 ? (
+
{t('noTasks')}
+ ) : (
+ labCase.tasksByTooth.map((group, groupIndex) => (
+
+
+
+ {group.prosthesisTypeLabel}
+
+
+ {t('toothGroupTitle', {
+ teeth: formatToothList(group.teeth),
+ prosthesis: group.prosthesisTypeLabel,
+ })}
+
+
+
+ {group.tasks.map((task) => (
+
+
+
+ {task.stepOrder}. {task.stepLabel}
+
+
+ {statusOptions.find((opt) => opt.value === task.status)?.label ??
+ task.status}
+
+
+
+ {task.lastStatusChangedBy
+ ? t('lastUpdatedBy', { name: task.lastStatusChangedBy.name })
+ : t('lastUpdatedUnknown')}
+ {task.lastStatusChangedAt
+ ? ` · ${formatCaseDateTime(task.lastStatusChangedAt, locale)}`
+ : ''}
+
+
+ ))}
+
+
+ ))
+ )}
+
+
+ {commentsSection}
+
+
setAttachmentsDialogOpen(false)}
+ caseId={labCase.id}
+ attachments={labCase.attachments}
+ loadBlob={loadAttachmentBlob}
+ />
+
+ );
+}
+
+export { CaseTaskProgressBar };
diff --git a/frontend/src/components/ui/lab/CaseToothChartPanel.tsx b/frontend/src/components/ui/lab/CaseToothChartPanel.tsx
index b9fd80d..acc4513 100644
--- a/frontend/src/components/ui/lab/CaseToothChartPanel.tsx
+++ b/frontend/src/components/ui/lab/CaseToothChartPanel.tsx
@@ -19,6 +19,7 @@ interface CaseToothChartPanelProps {
/** Prosthesis mapping from case tasks or toothProsthesis rows. */
prosthesisRows: CaseToothChartProsthesisRow[];
scale?: number;
+ compact?: boolean;
className?: string;
}
@@ -26,7 +27,8 @@ interface CaseToothChartPanelProps {
export function CaseToothChartPanel({
details,
prosthesisRows,
- scale = 0.5,
+ scale = 1,
+ compact = true,
className = '',
}: CaseToothChartPanelProps) {
const selected = useMemo(() => {
@@ -56,7 +58,7 @@ export function CaseToothChartPanel({
readOnly
scale={scale}
toothColors={toothColors}
- compact
+ compact={compact}
className={className}
/>
);
diff --git a/frontend/src/components/ui/lab/LabCaseAttachmentPreview.tsx b/frontend/src/components/ui/lab/LabCaseAttachmentPreview.tsx
index 00a2f82..1fcc5b3 100644
--- a/frontend/src/components/ui/lab/LabCaseAttachmentPreview.tsx
+++ b/frontend/src/components/ui/lab/LabCaseAttachmentPreview.tsx
@@ -15,7 +15,7 @@ export function LabCaseAttachmentPreview({
caseId,
attachment,
loadBlob,
- className = 'aspect-square w-full max-w-[11rem]',
+ className = 'h-full w-full',
}: LabCaseAttachmentPreviewProps) {
const [url, setUrl] = useState(null);
const [failed, setFailed] = useState(false);
diff --git a/frontend/src/components/ui/lab/LabCaseAttachmentsDialog.tsx b/frontend/src/components/ui/lab/LabCaseAttachmentsDialog.tsx
new file mode 100644
index 0000000..e6a63fa
--- /dev/null
+++ b/frontend/src/components/ui/lab/LabCaseAttachmentsDialog.tsx
@@ -0,0 +1,211 @@
+'use client';
+
+import { useCallback, useEffect, useState } from 'react';
+import { useTranslations } from 'next-intl';
+import { Download, FileText } from 'lucide-react';
+import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
+import { Button } from '@/components/ui/shared/Button';
+import type { LabCaseAttachmentMeta } from '@/types/cases';
+
+interface LabCaseAttachmentsDialogProps {
+ open: boolean;
+ onClose: () => void;
+ caseId: string;
+ attachments: LabCaseAttachmentMeta[];
+ loadBlob: (caseId: string, attachmentId: string) => Promise;
+}
+
+function downloadBlob(blob: Blob, fileName: string) {
+ const url = URL.createObjectURL(blob);
+ const anchor = document.createElement('a');
+ anchor.href = url;
+ anchor.download = fileName;
+ anchor.click();
+ URL.revokeObjectURL(url);
+}
+
+function formatFileSize(bytes: number): string {
+ if (bytes < 1024) return `${bytes} B`;
+ const kb = bytes / 1024;
+ if (kb < 1024) return `${kb.toFixed(1)} KB`;
+ return `${(kb / 1024).toFixed(1)} MB`;
+}
+
+function AttachmentPreviewTile({
+ caseId,
+ attachment,
+ loadBlob,
+ onDownload,
+}: {
+ caseId: string;
+ attachment: LabCaseAttachmentMeta;
+ loadBlob: (caseId: string, attachmentId: string) => Promise;
+ onDownload: (blob: Blob, fileName: string) => void;
+}) {
+ const t = useTranslations('cases');
+ const [url, setUrl] = useState(null);
+ const [blob, setBlob] = useState(null);
+ const [failed, setFailed] = useState(false);
+ const [downloading, setDownloading] = useState(false);
+
+ useEffect(() => {
+ let cancelled = false;
+ let objectUrl: string | null = null;
+
+ void (async () => {
+ try {
+ const loaded = await loadBlob(caseId, attachment.id);
+ if (cancelled) return;
+ objectUrl = URL.createObjectURL(loaded);
+ setBlob(loaded);
+ setUrl(objectUrl);
+ setFailed(false);
+ } catch {
+ if (!cancelled) setFailed(true);
+ }
+ })();
+
+ return () => {
+ cancelled = true;
+ if (objectUrl) URL.revokeObjectURL(objectUrl);
+ };
+ }, [caseId, attachment.id, loadBlob]);
+
+ const isImage = attachment.mimeType.startsWith('image/');
+ const isPdf = attachment.mimeType === 'application/pdf';
+
+ return (
+
+
+ {url && isImage ? (
+
+ ) : url && isPdf ? (
+
+ ) : (
+
+
+
+ {failed ? t('attachmentPreviewUnavailable') : attachment.fileName}
+
+
+ )}
+
+
+
+
+
+ {attachment.fileName}
+
+
+ {formatFileSize(attachment.sizeBytes)}
+
+
+
{
+ if (!blob) return;
+ setDownloading(true);
+ try {
+ onDownload(blob, attachment.fileName);
+ } finally {
+ setDownloading(false);
+ }
+ }}
+ >
+
+ {t('downloadAttachment')}
+
+
+
+ );
+}
+
+export function LabCaseAttachmentsDialog({
+ open,
+ onClose,
+ caseId,
+ attachments,
+ loadBlob,
+}: LabCaseAttachmentsDialogProps) {
+ const t = useTranslations('cases');
+
+ const sortedAttachments = [...attachments].sort(
+ (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
+ );
+
+ const handleDownload = useCallback((blob: Blob, fileName: string) => {
+ downloadBlob(blob, fileName);
+ }, []);
+
+ const [downloadingAll, setDownloadingAll] = useState(false);
+
+ const handleDownloadAll = useCallback(async () => {
+ if (sortedAttachments.length === 0) return;
+ setDownloadingAll(true);
+ try {
+ for (const attachment of sortedAttachments) {
+ const blob = await loadBlob(caseId, attachment.id);
+ downloadBlob(blob, attachment.fileName);
+ }
+ } finally {
+ setDownloadingAll(false);
+ }
+ }, [caseId, loadBlob, sortedAttachments]);
+
+ if (!open) return null;
+
+ return (
+
+
+
+
+
+ {t('attachmentsDialogTitle')}
+
+
{t('attachmentsDialogSubtitle')}
+
+
+ {sortedAttachments.length > 1 ? (
+ void handleDownloadAll()}
+ >
+
+ {t('downloadAllAttachments')}
+
+ ) : null}
+
+
+
+
+ {sortedAttachments.length === 0 ? (
+
{t('noAttachments')}
+ ) : (
+
+ {sortedAttachments.map((attachment) => (
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/components/ui/lab/LabCaseCommentsPanel.tsx b/frontend/src/components/ui/lab/LabCaseCommentsPanel.tsx
index cf4c664..ff666c5 100644
--- a/frontend/src/components/ui/lab/LabCaseCommentsPanel.tsx
+++ b/frontend/src/components/ui/lab/LabCaseCommentsPanel.tsx
@@ -1,10 +1,9 @@
'use client';
-import { useCallback, useEffect, useState } from 'react';
+import { useCallback, useEffect, useState, type KeyboardEvent } from 'react';
import { useTranslations } from 'next-intl';
-import { Eye, EyeOff } from 'lucide-react';
+import { Eye, EyeOff, Send } from 'lucide-react';
import { formatApiErrorMessage } from '@/components/shared/formatApiError';
-import { Button } from '@/components/ui/shared/Button';
import type { LabCaseComment } from '@/types/cases';
interface LabCaseCommentsPanelProps {
@@ -15,6 +14,13 @@ interface LabCaseCommentsPanelProps {
onPost: (body: string, visibleToClinic?: boolean) => Promise;
onToggleVisibility?: (commentId: string, visible: boolean) => Promise;
onError?: (message: string) => void;
+ /**
+ * Deferred composer: the parent owns the draft value and triggers the post
+ * elsewhere (e.g. the "Send to lab" button). No send icon is shown.
+ */
+ deferSubmit?: boolean;
+ composerValue?: string;
+ onComposerValueChange?: (value: string) => void;
}
export function LabCaseCommentsPanel({
@@ -25,6 +31,9 @@ export function LabCaseCommentsPanel({
onPost,
onToggleVisibility,
onError,
+ deferSubmit = false,
+ composerValue,
+ onComposerValueChange,
}: LabCaseCommentsPanelProps) {
const t = useTranslations('caseComments');
const [comments, setComments] = useState([]);
@@ -51,7 +60,7 @@ export function LabCaseCommentsPanel({
async function handlePost() {
const trimmed = body.trim();
- if (!trimmed || !canPost) return;
+ if (!trimmed || !canPost || posting) return;
setPosting(true);
try {
const created = await onPost(trimmed, visibleToClinic);
@@ -65,6 +74,13 @@ export function LabCaseCommentsPanel({
}
}
+ function handleComposerKeyDown(event: KeyboardEvent) {
+ if (event.key === 'Enter' && !event.shiftKey) {
+ event.preventDefault();
+ void handlePost();
+ }
+ }
+
async function handleToggle(comment: LabCaseComment) {
if (!onToggleVisibility || !canToggleVisibility) return;
try {
@@ -112,12 +128,8 @@ export function LabCaseCommentsPanel({
type="button"
onClick={() => void handleToggle(comment)}
className="shrink-0 p-1 rounded hover:bg-border text-text-muted"
- title={
- comment.visibleToClinic ? t('makeHidden') : t('makeVisible')
- }
- aria-label={
- comment.visibleToClinic ? t('makeHidden') : t('makeVisible')
- }
+ title={comment.visibleToClinic ? t('makeHidden') : t('makeVisible')}
+ aria-label={comment.visibleToClinic ? t('makeHidden') : t('makeVisible')}
>
{comment.visibleToClinic ? (
@@ -132,33 +144,54 @@ export function LabCaseCommentsPanel({
)}
- {canPost ? (
-
+ {canPost && deferSubmit ? (
+
+ ) : canPost ? (
+
) : null}
diff --git a/frontend/src/components/ui/lab/caseDetailUtils.ts b/frontend/src/components/ui/lab/caseDetailUtils.ts
new file mode 100644
index 0000000..bf477b2
--- /dev/null
+++ b/frontend/src/components/ui/lab/caseDetailUtils.ts
@@ -0,0 +1,40 @@
+import type { LabCaseDetail } from '@/types/cases';
+import type { CaseToothChartProsthesisRow } from '@/components/ui/lab/CaseToothChartPanel';
+
+export function formatPatientName(patient: { firstName: string; lastName: string }) {
+ return `${patient.firstName} ${patient.lastName}`.trim();
+}
+
+export function formatCaseDateTime(value: string | null, locale: string) {
+ if (!value) return '—';
+ return new Intl.DateTimeFormat(locale, {
+ dateStyle: 'medium',
+ timeStyle: 'short',
+ }).format(new Date(value));
+}
+
+export function buildCaseProsthesisRows(labCase: LabCaseDetail): CaseToothChartProsthesisRow[] {
+ if (labCase.toothProsthesis.length > 0) {
+ const byCode = new Map();
+ for (const row of labCase.toothProsthesis) {
+ const teeth = byCode.get(row.prosthesisTypeCode) ?? [];
+ if (!teeth.includes(row.tooth)) teeth.push(row.tooth);
+ byCode.set(row.prosthesisTypeCode, teeth);
+ }
+ return [...byCode.entries()].map(([prosthesisTypeCode, teeth]) => ({
+ prosthesisTypeCode,
+ teeth,
+ }));
+ }
+ return labCase.tasksByTooth.map((g) => ({
+ prosthesisTypeCode: g.prosthesisTypeCode,
+ teeth: g.teeth,
+ }));
+}
+
+export function latestCaseAttachment(labCase: LabCaseDetail) {
+ if (!labCase.attachments.length) return null;
+ return [...labCase.attachments].sort(
+ (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
+ )[0];
+}
diff --git a/frontend/src/components/ui/organizations/ConnectionCaseHistoryContent.tsx b/frontend/src/components/ui/organizations/ConnectionCaseHistoryContent.tsx
index 696c191..48f9724 100644
--- a/frontend/src/components/ui/organizations/ConnectionCaseHistoryContent.tsx
+++ b/frontend/src/components/ui/organizations/ConnectionCaseHistoryContent.tsx
@@ -2,65 +2,30 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslations } from 'next-intl';
-import { MessageSquare } from 'lucide-react';
import { formatApiErrorMessage } from '@/components/shared/formatApiError';
+import { canEditCases } from '@/components/shared/permissions';
import { useAuth } from '@/lib/hooks/useAuth';
import { useToast } from '@/lib/hooks/useToast';
import { organizationApi } from '@/lib/api/organization';
import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
import { treatmentTypeLabelFromCatalog } from '@/components/ui/treatment/treatmentTypeDisplay';
-import { Badge } from '@/components/ui/shared/Badge';
import { Button } from '@/components/ui/shared/Button';
import { SearchBar } from '@/components/ui/shared/SearchBar';
import { ToastStack } from '@/components/ui/shared/Toast';
+import { CaseDetailPanel, CaseTaskProgressBar } from '@/components/ui/lab/CaseDetailPanel';
import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel';
-import { CaseToothChartPanel } from '@/components/ui/lab/CaseToothChartPanel';
-import { LabCaseAttachmentPreview } from '@/components/ui/lab/LabCaseAttachmentPreview';
-import { labTaskStatusVariant } from '@/components/ui/lab/labTaskStatusDisplay';
import {
- formatToothList,
- prosthesisTypeBadgeStyle,
-} from '@/components/ui/treatment/prosthesisTypeDisplay';
+ formatCaseDateTime,
+ formatPatientName,
+} from '@/components/ui/lab/caseDetailUtils';
import { treatmentsApi } from '@/lib/api/treatments';
+import { casesApi } from '@/lib/api/cases';
import type { CounterpartItemDto } from '@/lib/api/organization';
import type { LabCaseDetail, LabCaseListItem, LabTaskStatus } from '@/types/cases';
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
const PAGE_SIZE = 20;
-function formatPatientName(patient: { firstName: string; lastName: string }) {
- return `${patient.firstName} ${patient.lastName}`.trim();
-}
-
-function formatDateTime(value: string | null, locale: string) {
- if (!value) return '—';
- return new Intl.DateTimeFormat(locale, {
- dateStyle: 'medium',
- timeStyle: 'short',
- }).format(new Date(value));
-}
-
-function TaskProgressBar({ completed, total }: { completed: number; total: number }) {
- const pct = total > 0 ? Math.round((completed / total) * 100) : 0;
-
- return (
-
-
-
- {completed}/{total}
-
- {pct}%
-
-
-
- );
-}
-
interface ConnectionCaseHistoryContentProps {
connection: CounterpartItemDto;
onBack: () => void;
@@ -90,10 +55,12 @@ export function ConnectionCaseHistoryContent({
const [treatmentCatalog, setTreatmentCatalog] = useState([]);
const [loadingList, setLoadingList] = useState(false);
const [loadingDetail, setLoadingDetail] = useState(false);
+ const [updatingImportant, setUpdatingImportant] = useState(false);
const [commentCount, setCommentCount] = useState(0);
const locale = user?.language ?? 'en';
const isClinic = currentOrganization?.type === 'CLINIC';
+ const canEditImportant = !isClinic && canEditCases(currentOrganization);
const tRef = useRef(t);
tRef.current = t;
@@ -194,33 +161,24 @@ export function ConnectionCaseHistoryContent({
[],
);
- const latestCaseAttachment = useMemo(() => {
- if (!selectedCase?.attachments.length) return null;
- return [...selectedCase.attachments].sort(
- (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
- )[0];
- }, [selectedCase?.attachments]);
+ async function handleCaseImportantToggle(isImportant: boolean) {
+ if (!selectedCaseId || !canEditImportant || !selectedCase) return;
- const caseProsthesisRows = useMemo(() => {
- if (!selectedCase) return [];
- if (selectedCase.toothProsthesis.length > 0) {
- const byCode = new Map();
- for (const row of selectedCase.toothProsthesis) {
- const key = row.prosthesisTypeCode;
- const teeth = byCode.get(key) ?? [];
- if (!teeth.includes(row.tooth)) teeth.push(row.tooth);
- byCode.set(key, teeth);
- }
- return [...byCode.entries()].map(([prosthesisTypeCode, teeth]) => ({
- prosthesisTypeCode,
- teeth,
- }));
+ const previousCase = selectedCase;
+ setSelectedCase({ ...selectedCase, isImportant });
+
+ setUpdatingImportant(true);
+ setError('');
+ try {
+ const response = await casesApi.setCaseImportant(selectedCaseId, isImportant);
+ setSelectedCase(response.data);
+ } catch (error: unknown) {
+ setSelectedCase(previousCase);
+ showError(formatApiErrorMessage(error, tCases('errorUpdateTask')));
+ } finally {
+ setUpdatingImportant(false);
}
- return selectedCase.tasksByTooth.map((g) => ({
- prosthesisTypeCode: g.prosthesisTypeCode,
- teeth: g.teeth,
- }));
- }, [selectedCase]);
+ }
return (
@@ -286,13 +244,13 @@ export function ConnectionCaseHistoryContent({
{item.clinic.name}
) : null}
- {formatDateTime(item.sentAt, locale)}
+ {formatCaseDateTime(item.sentAt, locale)}
{item.treatmentTypes.map(treatmentLabel).join(', ')}
-
@@ -340,25 +298,20 @@ export function ConnectionCaseHistoryContent({
) : loadingDetail || !selectedCase ? (
{tCommon('loading')}
) : (
-
-
-
-
- {formatPatientName(selectedCase.patient)}
-
- {isClinic ? (
-
-
- {commentCount > 0
- ? tCases('commentsCount', { count: commentCount })
- : tCases('showComments')}
-
- ) : null}
-
-
- {tCases('patientMobile')}: {selectedCase.patient.mobile}
-
- {!isClinic ? (
+ void handleCaseImportantToggle(checked)}
+ headerMetaLines={
+ !isClinic ? (
{tCases('fromClinic', { name: selectedCase.clinic.name })}
@@ -366,148 +319,38 @@ export function ConnectionCaseHistoryContent({
{t('caseHistorySentToLab', { name: connection.organizationName })}
- )}
-
- {tCases('sentAt', { date: formatDateTime(selectedCase.sentAt, locale) })}
-
-
-
- {tCases('taskProgressLabel', {
- completed: selectedCase.taskProgress.completed,
- total: selectedCase.taskProgress.total,
- })}
-
-
-
-
-
-
-
- {latestCaseAttachment && selectedCaseId ? (
-
-
- {tCases('latestAttachment')}
-
-
- ) : null}
-
-
- {selectedCase.details.length > 0 && (
-
-
- {tCases('treatmentDetails')}
-
-
-
- )}
-
-
-
{tCases('tasksByTooth')}
- {selectedCase.tasksByTooth.length === 0 ? (
-
{tCases('noTasks')}
- ) : (
- selectedCase.tasksByTooth.map((group, groupIndex) => (
-
-
-
- {group.prosthesisTypeLabel}
-
-
- {tCases('toothGroupTitle', {
- teeth: formatToothList(group.teeth),
- prosthesis: group.prosthesisTypeLabel,
- })}
-
-
-
- {group.tasks.map((task) => (
-
-
- {task.stepOrder}. {task.stepLabel}
-
-
- {statusOptions.find((opt) => opt.value === task.status)?.label ??
- task.status}
-
- {task.lastStatusChangedBy ? (
-
- {tCases('lastUpdatedBy', { name: task.lastStatusChangedBy.name })}
-
- ) : null}
-
- ))}
-
-
- ))
- )}
-
-
- {isClinic && selectedCaseId ? (
-
- ) : null}
-
+
+ ) : null
+ }
+ />
)}
diff --git a/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx b/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx
index fe588ae..956c636 100644
--- a/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx
+++ b/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx
@@ -33,7 +33,7 @@ interface LabCasesDispatchPanelProps {
onRecentOrganizationPick: (orgId: string) => void;
sendBusyId: string | null;
onAddLabCase: () => void;
- onSendLabCase: (labCase: LabCaseDraft) => void;
+ onSendLabCase: (labCase: LabCaseDraft, comment?: string) => void;
onCommentError?: (message: string) => void;
}
@@ -133,6 +133,7 @@ export function LabCasesDispatchPanel({
const t = useTranslations('treatment');
const [prosthesisOptions, setProsthesisOptions] = useState
([]);
const [applyAllProsthesis, setApplyAllProsthesis] = useState('');
+ const [pendingComment, setPendingComment] = useState('');
const activeLinkedOrganizations = orgs.filter((o) => o.active);
const filteredOrganizations = (() => {
@@ -196,6 +197,11 @@ export function LabCasesDispatchPanel({
};
}, [activeLabCase?.destinationOrganizationId]);
+ // Reset the pending (unposted) comment when switching to another shipment.
+ useEffect(() => {
+ setPendingComment('');
+ }, [activeLabCase?.clientId]);
+
// Hide dispatch when the selected treatment detail is not lab-dependent.
if (!activeDetail || !isLabDependentDetail) {
return null;
@@ -428,6 +434,9 @@ export function LabCasesDispatchPanel({
caseId={activeLabCase.id}
canPost={canEdit && !disabled}
canToggleVisibility={false}
+ deferSubmit
+ composerValue={pendingComment}
+ onComposerValueChange={setPendingComment}
loadComments={async () => {
const r = await treatmentsApi.listLabCaseComments(activeLabCase.id!);
return r.data;
@@ -578,7 +587,7 @@ export function LabCasesDispatchPanel({
!prosthesisComplete
}
isLoading={sendBusyId === activeLabCase.clientId}
- onClick={() => onSendLabCase(activeLabCase)}
+ onClick={() => onSendLabCase(activeLabCase, pendingComment.trim())}
>
{t('sendToLab')}
diff --git a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx
index d099064..4306d96 100644
--- a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx
+++ b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx
@@ -109,13 +109,24 @@ function buildWorkspaceSnapshot(
};
}
-function newDetail(): TreatmentDetailDraft {
+function defaultTreatmentTypeForAppointment(
+ purpose: string | undefined,
+ catalog: TreatmentCatalogEntry[],
+): TreatmentDetailDraft['treatmentType'] {
+ const treatmentOptions = catalog.filter((entry) => entry.availableInTreatment);
+ if (purpose && treatmentOptions.some((entry) => entry.code === purpose)) {
+ return purpose as TreatmentDetailDraft['treatmentType'];
+ }
+ return (treatmentOptions[0]?.code ?? 'restoration') as TreatmentDetailDraft['treatmentType'];
+}
+
+function newDetail(defaultTreatmentType?: TreatmentDetailDraft['treatmentType']): TreatmentDetailDraft {
return {
clientId:
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: `detail-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
- treatmentType: 'restoration',
+ treatmentType: defaultTreatmentType ?? 'restoration',
teeth: [],
comment: '',
attachmentMetas: [],
@@ -502,9 +513,13 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
}, [showError, t]);
useEffect(() => {
- if (selectedAppointment?.patientId) {
- setHistoryPatientId(selectedAppointment.patientId);
+ if (!selectedAppointment?.patientId) {
+ setHistoryPatientId(null);
+ setHistory([]);
+ setHistoryLoading(false);
+ return;
}
+ setHistoryPatientId(selectedAppointment.patientId);
}, [selectedAppointment?.patientId]);
useEffect(() => {
@@ -558,7 +573,9 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
});
setSavedSnapshot(serializeDetails(mapped));
} else {
- const first = newDetail();
+ const first = newDetail(
+ defaultTreatmentTypeForAppointment(selectedAppointment?.purpose, treatmentCatalog),
+ );
setDetails([first]);
setActiveDetailId(first.clientId);
setSavedSnapshot(serializeDetails([first]));
@@ -583,7 +600,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
cancelled = true;
draftHydratingRef.current = false;
};
- }, [selectedAppointment?.id, workspaceMode, showError, t]);
+ }, [selectedAppointment?.id, selectedAppointment?.purpose, workspaceMode, treatmentCatalog, showError, t]);
const persistDraft = useCallback(
async (options?: { force?: boolean }) => {
@@ -905,7 +922,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
]);
const handleSendLabCase = useCallback(
- async (labCase: LabCaseDraft) => {
+ async (labCase: LabCaseDraft, comment?: string) => {
if (!canEditTreatmentForDay || !selectedAppointment) return;
if (!labCase.destinationOrganizationId) {
showError(t('errorChooseOrg'));
@@ -934,6 +951,11 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
);
if (!refreshedLabCase?.id) throw new Error(t('errorSendCase'));
+ const trimmedComment = comment?.trim();
+ if (trimmedComment) {
+ await treatmentsApi.addLabCaseComment(refreshedLabCase.id, { body: trimmedComment });
+ }
+
const response = await treatmentsApi.sendLabCase(refreshedLabCase.id);
const sentDetailClientIds = new Set(labCase.detailClientIds);
@@ -1117,7 +1139,9 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
saveStatus={saveStatus}
uploadBusy={uploadBusyDetailId === activeDetailId}
onAddDetail={() => {
- const next = newDetail();
+ const next = newDetail(
+ defaultTreatmentTypeForAppointment(selectedAppointment?.purpose, treatmentCatalog),
+ );
setDetails((prev) => [...prev, next]);
setActiveDetailId(next.clientId);
}}
@@ -1151,7 +1175,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
}}
sendBusyId={sendBusyId}
onAddLabCase={() => void handleAddLabCase()}
- onSendLabCase={(lc) => void handleSendLabCase(lc)}
+ onSendLabCase={(lc, comment) => void handleSendLabCase(lc, comment)}
onCommentError={showError}
/>
diff --git a/frontend/src/lib/api/cases.ts b/frontend/src/lib/api/cases.ts
index ff8dcab..577701f 100644
--- a/frontend/src/lib/api/cases.ts
+++ b/frontend/src/lib/api/cases.ts
@@ -2,7 +2,6 @@ import { apiClient } from './client';
import type {
CasesFilterOptions,
LabCaseDetail,
- LabCaseTask,
ListLabCasesParams,
PaginatedLabCases,
} from '@/types/cases';
@@ -25,12 +24,11 @@ export const casesApi = {
return response.data;
},
- setTaskImportant: async (
+ setCaseImportant: async (
caseId: string,
- taskId: string,
isImportant: boolean,
- ): Promise<{ success: boolean; data: LabCaseTask }> => {
- const response = await apiClient.patch(`/cases/${caseId}/tasks/${taskId}`, { isImportant });
+ ): Promise<{ success: boolean; data: LabCaseDetail }> => {
+ const response = await apiClient.patch(`/cases/${caseId}/important`, { isImportant });
return response.data;
},
diff --git a/frontend/src/types/cases.ts b/frontend/src/types/cases.ts
index 4d6d08d..8e17245 100644
--- a/frontend/src/types/cases.ts
+++ b/frontend/src/types/cases.ts
@@ -38,7 +38,6 @@ export interface LabCaseTask {
stepOrder: number;
stepLabel: string;
status: LabTaskStatus;
- isImportant: boolean;
createdAt: string;
lastStatusChangedAt: string | null;
lastStatusChangedBy: LabTaskUser | null;
@@ -77,6 +76,7 @@ export interface LabCaseAttachmentMeta {
export interface LabCaseDetail {
id: string;
sentAt: string | null;
+ isImportant: boolean;
clinic: { id: string; name: string };
patient: {
id: string;
@@ -133,7 +133,14 @@ export interface PaginatedLabCases {
};
}
-export type TaskSortField = 'date' | 'status' | 'clinic' | 'patient' | 'important';
+export type TaskSortField =
+ | 'date'
+ | 'status'
+ | 'clinic'
+ | 'patient'
+ | 'important'
+ | 'prosthesis'
+ | 'taskType';
export interface ListLabTasksParams {
q?: string;
--
2.53.0.windows.1