From 334b7841ae9986632841949f128a8987f244404e Mon Sep 17 00:00:00 2001 From: Admin Date: Thu, 16 Jul 2026 22:35:02 +0330 Subject: [PATCH 01/13] improvement: treatment exits contoll adde to appoinment remove action. --- backend/src/common/errors/error-codes.ts | 1 + .../appointments/appointments.service.ts | 12 +- frontend/messages/en.json | 2 + frontend/messages/fa.json | 2 + frontend/messages/nl.json | 2 + .../appointments/AppointmentBookingModal.tsx | 712 +++++++++--------- .../ui/appointments/AppointmentsPage.tsx | 581 +++++++------- 7 files changed, 666 insertions(+), 646 deletions(-) diff --git a/backend/src/common/errors/error-codes.ts b/backend/src/common/errors/error-codes.ts index 51932fa..e1c6608 100644 --- a/backend/src/common/errors/error-codes.ts +++ b/backend/src/common/errors/error-codes.ts @@ -63,6 +63,7 @@ export const ErrorCode = { CONFLICT: 'CONFLICT', CONFLICT_FUTURE_APPOINTMENTS: 'CONFLICT_FUTURE_APPOINTMENTS', APPOINTMENT_PATIENT_LOCKED: 'APPOINTMENT_PATIENT_LOCKED', + APPOINTMENT_HAS_TREATMENT: 'APPOINTMENT_HAS_TREATMENT', BAD_REQUEST: 'BAD_REQUEST', INTERNAL_ERROR: 'INTERNAL_ERROR', } as const; diff --git a/backend/src/modules/appointments/appointments.service.ts b/backend/src/modules/appointments/appointments.service.ts index 7af2525..0a4755f 100644 --- a/backend/src/modules/appointments/appointments.service.ts +++ b/backend/src/modules/appointments/appointments.service.ts @@ -1,6 +1,7 @@ import { BadRequestException, ForbiddenException, + HttpStatus, Injectable, NotFoundException, } from '@nestjs/common'; @@ -16,6 +17,7 @@ import { ListAppointmentsDto } from './dto/list-appointments.dto'; import { UpdateAppointmentDto } from './dto/update-appointment.dto'; import { TreatmentCatalogService } from '../treatment-catalog/treatment-catalog.service'; import { hasEffectivePermission } from '../../common/membership-permissions'; +import { AppException, ErrorCode } from '../../common/errors'; const MS_PER_DAY = 86_400_000; @@ -215,9 +217,7 @@ export class AppointmentsService { select: { id: true }, }); if (linkedTreatment) { - throw new BadRequestException( - 'Cannot change the patient while a treatment is linked to this appointment', - ); + throw new AppException(ErrorCode.APPOINTMENT_PATIENT_LOCKED, HttpStatus.CONFLICT); } } @@ -256,13 +256,17 @@ export class AppointmentsService { const existing = await this.prisma.appointment.findFirst({ where: { id, organizationId }, - select: { id: true }, + select: { id: true, treatment: { select: { id: true } } }, }); if (!existing) { throw new NotFoundException('Appointment not found'); } + if (existing.treatment) { + throw new AppException(ErrorCode.APPOINTMENT_HAS_TREATMENT, HttpStatus.CONFLICT); + } + await this.prisma.appointment.delete({ where: { id }, }); diff --git a/frontend/messages/en.json b/frontend/messages/en.json index b248bab..ae24ff6 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -582,6 +582,7 @@ "confirmRemove": "Remove this appointment?", "successRemoved": "Appointment removed.", "errorDelete": "Could not delete appointment.", + "deleteBlockedHint": "This appointment cannot be deleted because a treatment is linked to it.", "errorLoadSchedule": "Failed to load schedule.", "successPatientSaved": "Patient {firstName} {lastName} was saved.", "searchPlaceholder": "Search existing patients", @@ -1013,6 +1014,7 @@ "CONFLICT": "This action conflicts with existing data.", "CONFLICT_FUTURE_APPOINTMENTS": "You cannot stop participating in treatments while you have future appointments. Reassign or cancel them first.", "APPOINTMENT_PATIENT_LOCKED": "Cannot change the patient while a treatment is linked to this appointment.", + "APPOINTMENT_HAS_TREATMENT": "This appointment cannot be deleted because a treatment is linked to it.", "BAD_REQUEST": "The request could not be processed.", "INTERNAL_ERROR": "Something went wrong on our end. Please try again later." } diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index c3ee1ca..0f0e74b 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -583,6 +583,7 @@ "confirmRemove": "این نوبت حذف شود؟", "successRemoved": "نوبت حذف شد.", "errorDelete": "حذف نوبت امکان‌پذیر نبود.", + "deleteBlockedHint": "به‌دلیل وجود درمان مرتبط با این نوبت، امکان حذف آن وجود ندارد.", "errorLoadSchedule": "بارگذاری برنامه ناموفق بود.", "successPatientSaved": "بیمار {firstName} {lastName} ذخیره شد.", "searchPlaceholder": "جستجوی بیماران موجود", @@ -1014,6 +1015,7 @@ "CONFLICT": "این عمل با داده‌های موجود در تضاد است.", "CONFLICT_FUTURE_APPOINTMENTS": "تا وقتی نوبت‌های آینده دارید نمی‌توانید مشارکت در درمان را متوقف کنید. ابتدا آن‌ها را لغو یا واگذار کنید.", "APPOINTMENT_PATIENT_LOCKED": "تا وقتی درمانی به این نوبت متصل است، امکان تغییر بیمار وجود ندارد.", + "APPOINTMENT_HAS_TREATMENT": "به‌دلیل وجود درمان مرتبط با این نوبت، امکان حذف آن وجود ندارد.", "BAD_REQUEST": "درخواست قابل پردازش نبود.", "INTERNAL_ERROR": "مشکلی در سرور رخ داد. لطفاً بعداً تلاش کنید." } diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index fa4556f..d3f94cd 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -582,6 +582,7 @@ "confirmRemove": "Deze afspraak verwijderen?", "successRemoved": "Afspraak verwijderd.", "errorDelete": "Kon afspraak niet verwijderen.", + "deleteBlockedHint": "Deze afspraak kan niet worden verwijderd omdat er een behandeling aan is gekoppeld.", "errorLoadSchedule": "Rooster laden mislukt.", "successPatientSaved": "Patiënt {firstName} {lastName} is opgeslagen.", "searchPlaceholder": "Bestaande patiënten zoeken", @@ -1013,6 +1014,7 @@ "CONFLICT": "Deze actie conflicteert met bestaande gegevens.", "CONFLICT_FUTURE_APPOINTMENTS": "U kunt niet stoppen met deelnemen aan behandelingen zolang u toekomstige afspraken hebt. Wijs ze eerst opnieuw toe of annuleer ze.", "APPOINTMENT_PATIENT_LOCKED": "De patiënt kan niet worden gewijzigd zolang er een behandeling aan deze afspraak is gekoppeld.", + "APPOINTMENT_HAS_TREATMENT": "Deze afspraak kan niet worden verwijderd omdat er een behandeling aan is gekoppeld.", "BAD_REQUEST": "Het verzoek kon niet worden verwerkt.", "INTERNAL_ERROR": "Er is iets misgegaan aan onze kant. Probeer het later opnieuw." } diff --git a/frontend/src/components/ui/appointments/AppointmentBookingModal.tsx b/frontend/src/components/ui/appointments/AppointmentBookingModal.tsx index 27d4fcd..b36111d 100644 --- a/frontend/src/components/ui/appointments/AppointmentBookingModal.tsx +++ b/frontend/src/components/ui/appointments/AppointmentBookingModal.tsx @@ -1,354 +1,358 @@ -'use client'; - -import { useEffect, useId, useState } from 'react'; -import { useTranslations } from 'next-intl'; -import { Button } from '@/components/ui/shared/Button'; -import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton'; -import { ResponsiveDialogOverlay, ResponsiveDialogPanel } from '@/components/ui/shared/ResponsiveDialog'; -import { Dropdown } from '@/components/ui/shared/Dropdown'; -import { TimeStepInput } from '@/components/ui/shared/TimeStepInput'; -import type { AppointmentPurpose, AppointmentRecord } from '@/types/appointment'; -import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; -import { - DROPDOWN_OPTION_BG, - treatmentTypeColor, -} from '@/components/shared/treatmentTypeDisplay'; -import type { Patient } from '@/types/patient'; -import { PatientSearchCombobox } from '@/components/ui/patient/PatientSearchCombobox'; -import { usePatientSearchQuery } from '@/lib/hooks/usePatientSearchQuery'; -import { - combineLocalDateAndTime, - compareLocalDayStart, - formatTimeForInput, - isSameLocalCalendarDay, -} from '@/components/appointments/appointmentTime'; - -const DEFAULT_DURATION_MINUTES = 30; - -interface AppointmentBookingModalProps { - open: boolean; - scheduleDate: Date; - /** Pre-selected patient from the page sidebar (optional). */ - initialPatient?: Patient; - onPatientChange?: (patient: Patient | undefined) => void; - canAddPatient?: boolean; - onAddPatient?: () => void; - providerUserId: string | null; - providerName: string; - initialStartMinute: number; - onClose: () => void; - onSubmit: (payload: { - patientId: string; - providerUserId: string; - startAt: string; - endAt: string; - purpose: AppointmentPurpose; - }) => Promise; - treatmentCatalog: TreatmentCatalogEntry[]; - editingAppointment?: AppointmentRecord | null; - loading?: boolean; - canDelete?: boolean; - onDelete?: () => void | Promise; - deleting?: boolean; -} - -function patientFromRecord( - patient: AppointmentRecord['patient'], -): Pick { - return { - id: patient.id, - firstName: patient.firstName, - lastName: patient.lastName, - mobile: patient.mobile, - }; -} - -export function AppointmentBookingModal({ - open, - scheduleDate, - initialPatient, - onPatientChange, - canAddPatient = false, - onAddPatient, - providerUserId, - providerName, - initialStartMinute, - onClose, - onSubmit, - treatmentCatalog, - editingAppointment = null, - loading = false, - canDelete = false, - onDelete, - deleting = false, -}: AppointmentBookingModalProps) { - const t = useTranslations('appointments'); - const tCommon = useTranslations('common'); - const startInputId = useId(); - const endInputId = useId(); - - const defaultPurpose = treatmentCatalog[0]?.code ?? ''; - - const [startTime, setStartTime] = useState('09:00'); - const [endTime, setEndTime] = useState('09:30'); - const [purpose, setPurpose] = useState(defaultPurpose); - const [selectedPatient, setSelectedPatient] = useState< - Pick | null - >(null); - const [error, setError] = useState(''); - - const { search, setSearch, patients, loading: loadingPatients } = usePatientSearchQuery(open); - - const patientLocked = Boolean(editingAppointment?.hasTreatment); - const purposeIndex = treatmentCatalog.findIndex((e) => e.code === purpose); - const purposeTextColor = treatmentTypeColor(purpose, purposeIndex < 0 ? 0 : purposeIndex); - - useEffect(() => { - if (!open) { - return; - } - setSearch(''); - if (editingAppointment) { - const start = new Date(editingAppointment.startAt); - const end = new Date(editingAppointment.endAt); - setStartTime(formatTimeForInput(start)); - setEndTime(formatTimeForInput(end)); - setPurpose(editingAppointment.purpose || defaultPurpose); - setSelectedPatient(patientFromRecord(editingAppointment.patient)); - } else { - const start = new Date( - scheduleDate.getFullYear(), - scheduleDate.getMonth(), - scheduleDate.getDate(), - Math.floor(initialStartMinute / 60), - initialStartMinute % 60, - 0, - 0, - ); - const endMinute = Math.min( - initialStartMinute + DEFAULT_DURATION_MINUTES, - 24 * 60 - 1, - ); - const end = new Date( - scheduleDate.getFullYear(), - scheduleDate.getMonth(), - scheduleDate.getDate(), - Math.floor(endMinute / 60), - endMinute % 60, - 0, - 0, - ); - setStartTime(formatTimeForInput(start)); - setEndTime(formatTimeForInput(end)); - setPurpose(defaultPurpose); - setSelectedPatient( - initialPatient - ? { - id: initialPatient.id, - firstName: initialPatient.firstName, - lastName: initialPatient.lastName, - mobile: initialPatient.mobile, - } - : null, - ); - } - setError(''); - }, [ - open, - scheduleDate, - initialStartMinute, - editingAppointment?.id, - defaultPurpose, - initialPatient?.id, - setSearch, - ]); - - if (!open || !providerUserId) { - return null; - } - - function selectPatient(patient: Patient) { - if (patientLocked) { - return; - } - const next = { - id: patient.id, - firstName: patient.firstName, - lastName: patient.lastName, - mobile: patient.mobile, - }; - setSelectedPatient(next); - onPatientChange?.(patient); - if (error === t('errorSelectPatient')) { - setError(''); - } - } - - async function handleSubmit() { - setError(''); - if (!providerUserId) { - return; - } - if (!selectedPatient?.id) { - setError(t('errorSelectPatient')); - return; - } - - const startAt = combineLocalDateAndTime(scheduleDate, startTime); - const endAt = combineLocalDateAndTime(scheduleDate, endTime); - - if (endAt <= startAt) { - setError(t('errorEndAfterStart')); - return; - } - - const now = new Date(); - if (isSameLocalCalendarDay(scheduleDate, now) && startAt.getTime() < now.getTime()) { - setError(t('errorPastSchedule')); - return; - } - - const today = new Date(); - if (compareLocalDayStart(scheduleDate, today) < 0) { - setError(t('errorPastViewOnly')); - return; - } - - await onSubmit({ - patientId: selectedPatient.id, - providerUserId, - startAt: startAt.toISOString(), - endAt: endAt.toISOString(), - purpose, - }); - } - - const selectedForDisplay = selectedPatient - ? ({ - ...selectedPatient, - isActive: true, - createdAt: '', - updatedAt: '', - } satisfies Patient) - : null; - - return ( - - -
-

- {editingAppointment ? t('editTitle') : t('newTitle')} -

- -
- -

- {t('providerLabel')}{' '} - {providerName} -

- -
- - - -
- -
- - -
- - setPurpose(e.target.value)} - style={{ color: purposeTextColor }} - > - {treatmentCatalog.map((entry, index) => ( - - ))} - - - {error &&

{error}

} - -
- {editingAppointment && canDelete && onDelete ? ( - - ) : null} -
- - -
-
-
-
- ); -} +'use client'; + +import { useEffect, useId, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { Button } from '@/components/ui/shared/Button'; +import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton'; +import { ResponsiveDialogOverlay, ResponsiveDialogPanel } from '@/components/ui/shared/ResponsiveDialog'; +import { Dropdown } from '@/components/ui/shared/Dropdown'; +import { TimeStepInput } from '@/components/ui/shared/TimeStepInput'; +import type { AppointmentPurpose, AppointmentRecord } from '@/types/appointment'; +import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; +import { + DROPDOWN_OPTION_BG, + treatmentTypeColor, +} from '@/components/shared/treatmentTypeDisplay'; +import type { Patient } from '@/types/patient'; +import { PatientSearchCombobox } from '@/components/ui/patient/PatientSearchCombobox'; +import { usePatientSearchQuery } from '@/lib/hooks/usePatientSearchQuery'; +import { + combineLocalDateAndTime, + compareLocalDayStart, + formatTimeForInput, + isSameLocalCalendarDay, +} from '@/components/appointments/appointmentTime'; + +const DEFAULT_DURATION_MINUTES = 30; + +interface AppointmentBookingModalProps { + open: boolean; + scheduleDate: Date; + /** Pre-selected patient from the page sidebar (optional). */ + initialPatient?: Patient; + onPatientChange?: (patient: Patient | undefined) => void; + canAddPatient?: boolean; + onAddPatient?: () => void; + providerUserId: string | null; + providerName: string; + initialStartMinute: number; + onClose: () => void; + onSubmit: (payload: { + patientId: string; + providerUserId: string; + startAt: string; + endAt: string; + purpose: AppointmentPurpose; + }) => Promise; + treatmentCatalog: TreatmentCatalogEntry[]; + editingAppointment?: AppointmentRecord | null; + loading?: boolean; + canDelete?: boolean; + onDelete?: () => void | Promise; + deleting?: boolean; +} + +function patientFromRecord( + patient: AppointmentRecord['patient'], +): Pick { + return { + id: patient.id, + firstName: patient.firstName, + lastName: patient.lastName, + mobile: patient.mobile, + }; +} + +export function AppointmentBookingModal({ + open, + scheduleDate, + initialPatient, + onPatientChange, + canAddPatient = false, + onAddPatient, + providerUserId, + providerName, + initialStartMinute, + onClose, + onSubmit, + treatmentCatalog, + editingAppointment = null, + loading = false, + canDelete = false, + onDelete, + deleting = false, +}: AppointmentBookingModalProps) { + const t = useTranslations('appointments'); + const tCommon = useTranslations('common'); + const startInputId = useId(); + const endInputId = useId(); + + const defaultPurpose = treatmentCatalog[0]?.code ?? ''; + + const [startTime, setStartTime] = useState('09:00'); + const [endTime, setEndTime] = useState('09:30'); + const [purpose, setPurpose] = useState(defaultPurpose); + const [selectedPatient, setSelectedPatient] = useState< + Pick | null + >(null); + const [error, setError] = useState(''); + + const { search, setSearch, patients, loading: loadingPatients } = usePatientSearchQuery(open); + + const patientLocked = Boolean(editingAppointment?.hasTreatment); + const purposeIndex = treatmentCatalog.findIndex((e) => e.code === purpose); + const purposeTextColor = treatmentTypeColor(purpose, purposeIndex < 0 ? 0 : purposeIndex); + + useEffect(() => { + if (!open) { + return; + } + setSearch(''); + if (editingAppointment) { + const start = new Date(editingAppointment.startAt); + const end = new Date(editingAppointment.endAt); + setStartTime(formatTimeForInput(start)); + setEndTime(formatTimeForInput(end)); + setPurpose(editingAppointment.purpose || defaultPurpose); + setSelectedPatient(patientFromRecord(editingAppointment.patient)); + } else { + const start = new Date( + scheduleDate.getFullYear(), + scheduleDate.getMonth(), + scheduleDate.getDate(), + Math.floor(initialStartMinute / 60), + initialStartMinute % 60, + 0, + 0, + ); + const endMinute = Math.min( + initialStartMinute + DEFAULT_DURATION_MINUTES, + 24 * 60 - 1, + ); + const end = new Date( + scheduleDate.getFullYear(), + scheduleDate.getMonth(), + scheduleDate.getDate(), + Math.floor(endMinute / 60), + endMinute % 60, + 0, + 0, + ); + setStartTime(formatTimeForInput(start)); + setEndTime(formatTimeForInput(end)); + setPurpose(defaultPurpose); + setSelectedPatient( + initialPatient + ? { + id: initialPatient.id, + firstName: initialPatient.firstName, + lastName: initialPatient.lastName, + mobile: initialPatient.mobile, + } + : null, + ); + } + setError(''); + }, [ + open, + scheduleDate, + initialStartMinute, + editingAppointment?.id, + defaultPurpose, + initialPatient?.id, + setSearch, + ]); + + if (!open || !providerUserId) { + return null; + } + + function selectPatient(patient: Patient) { + if (patientLocked) { + return; + } + const next = { + id: patient.id, + firstName: patient.firstName, + lastName: patient.lastName, + mobile: patient.mobile, + }; + setSelectedPatient(next); + onPatientChange?.(patient); + if (error === t('errorSelectPatient')) { + setError(''); + } + } + + async function handleSubmit() { + setError(''); + if (!providerUserId) { + return; + } + if (!selectedPatient?.id) { + setError(t('errorSelectPatient')); + return; + } + + const startAt = combineLocalDateAndTime(scheduleDate, startTime); + const endAt = combineLocalDateAndTime(scheduleDate, endTime); + + if (endAt <= startAt) { + setError(t('errorEndAfterStart')); + return; + } + + const now = new Date(); + if (isSameLocalCalendarDay(scheduleDate, now) && startAt.getTime() < now.getTime()) { + setError(t('errorPastSchedule')); + return; + } + + const today = new Date(); + if (compareLocalDayStart(scheduleDate, today) < 0) { + setError(t('errorPastViewOnly')); + return; + } + + await onSubmit({ + patientId: selectedPatient.id, + providerUserId, + startAt: startAt.toISOString(), + endAt: endAt.toISOString(), + purpose, + }); + } + + const selectedForDisplay = selectedPatient + ? ({ + ...selectedPatient, + isActive: true, + createdAt: '', + updatedAt: '', + } satisfies Patient) + : null; + + return ( + + +
+

+ {editingAppointment ? t('editTitle') : t('newTitle')} +

+ +
+ +

+ {t('providerLabel')}{' '} + {providerName} +

+ +
+ + + +
+ +
+ + +
+ + setPurpose(e.target.value)} + style={{ color: purposeTextColor }} + > + {treatmentCatalog.map((entry, index) => ( + + ))} + + + {error &&

{error}

} + + {editingAppointment?.hasTreatment ? ( +

{t('deleteBlockedHint')}

+ ) : null} + +
+ {editingAppointment && canDelete && onDelete ? ( + + ) : null} +
+ + +
+
+
+
+ ); +} diff --git a/frontend/src/components/ui/appointments/AppointmentsPage.tsx b/frontend/src/components/ui/appointments/AppointmentsPage.tsx index 89e9886..17b72ce 100644 --- a/frontend/src/components/ui/appointments/AppointmentsPage.tsx +++ b/frontend/src/components/ui/appointments/AppointmentsPage.tsx @@ -1,288 +1,293 @@ -'use client'; - -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useTranslations } from 'next-intl'; -import { useRouter } from '@/i18n/navigation'; -import { appointmentsApi } from '@/lib/api/appointments'; -import { treatmentCatalogApi } from '@/lib/api/treatment-catalog'; -import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; -import { useAuth } from '@/lib/hooks/useAuth'; -import { usePatientSearchQuery } from '@/lib/hooks/usePatientSearchQuery'; -import { canEditAppointments, hasPermission } from '@/components/shared/permissions'; -import type { AppointmentColumnProvider, AppointmentRecord } from '@/types/appointment'; -import type { Patient } from '@/types/patient'; -import { PatientSummaryCard } from '@/components/ui/patient/PatientSummaryCard'; -import { PatientSearchCombobox } from '@/components/ui/patient/PatientSearchCombobox'; -import { AppointmentBookingModal } from '@/components/ui/appointments/AppointmentBookingModal'; -import { AppointmentScheduleGrid } from '@/components/ui/appointments/AppointmentScheduleGrid'; -import { AppointmentScheduleLegend } from '@/components/ui/appointments/AppointmentScheduleLegend'; -import { ScheduleDayPicker } from '@/components/ui/shared/ScheduleDayPicker'; -import { useToast } from '@/lib/hooks/useToast'; -import type { AppointmentPurpose } from '@/types/appointment'; -import { getUserFacingError } from '@/components/shared/formatApiError'; -import { compareLocalDayStart, getLocalDayIsoRange, startOfLocalDay } from '@/components/appointments/appointmentTime'; - -export function AppointmentsPage() { - const t = useTranslations('appointments'); - const tErrors = useTranslations('errors'); - const router = useRouter(); - const { currentOrganization } = useAuth(); - const [scheduleDate, setScheduleDate] = useState(() => startOfLocalDay(new Date())); - - const [providers, setProviders] = useState([]); - const [appointments, setAppointments] = useState([]); - const [treatmentCatalog, setTreatmentCatalog] = useState([]); - const [loadingSchedule, setLoadingSchedule] = useState(false); - const toast = useToast(); - - const { search, setSearch, patients, loading: loadingPatients } = usePatientSearchQuery(); - const [selectedPatient, setSelectedPatient] = useState(); - - const [bookingOpen, setBookingOpen] = useState(false); - const [bookingStartMinute, setBookingStartMinute] = useState(9 * 60); - const [bookingProviderId, setBookingProviderId] = useState(null); - const [bookingProviderName, setBookingProviderName] = useState(''); - const [editingAppointmentId, setEditingAppointmentId] = useState(null); - const [savingAppointment, setSavingAppointment] = useState(false); - const [deletingAppointment, setDeletingAppointment] = useState(false); - - const canManageAppointments = canEditAppointments(currentOrganization); - const canEditPatients = hasPermission(currentOrganization, 'TAB_PATIENTS_EDIT'); - - const todayStart = useMemo(() => startOfLocalDay(new Date()), []); - const isViewingPastDay = useMemo( - () => compareLocalDayStart(scheduleDate, todayStart) < 0, - [scheduleDate, todayStart], - ); - const activeEditingAppointment = useMemo( - () => appointments.find((a) => a.id === editingAppointmentId) ?? null, - [appointments, editingAppointmentId], - ); - - const scheduleLoadGen = useRef(0); - - const sortedPatients = useMemo( - () => - [...patients].sort((a, b) => - `${a.firstName} ${a.lastName}`.localeCompare(`${b.firstName} ${b.lastName}`), - ), - [patients], - ); - - const navigateToAddPatient = useCallback(() => { - if (!canEditPatients) { - return; - } - router.push('/patients?action=create'); - }, [canEditPatients, router]); - - const loadSchedule = useCallback(async () => { - if (!currentOrganization?.id) { - return; - } - const gen = ++scheduleLoadGen.current; - setLoadingSchedule(true); - toast.setError(''); - try { - const range = getLocalDayIsoRange(scheduleDate); - const [pRes, aRes] = await Promise.all([ - appointmentsApi.columnProviders(scheduleDate), - appointmentsApi.list(range), - ]); - if (gen !== scheduleLoadGen.current) { - return; - } - setProviders(pRes.data ?? []); - setAppointments(aRes.data ?? []); - } catch (err: unknown) { - if (gen !== scheduleLoadGen.current) { - return; - } - toast.showError(getUserFacingError(err, tErrors, t('errorLoadSchedule'))); - } finally { - if (gen === scheduleLoadGen.current) { - setLoadingSchedule(false); - } - } - }, [currentOrganization?.id, scheduleDate, t, tErrors, toast]); - - useEffect(() => { - void loadSchedule(); - }, [loadSchedule]); - - useEffect(() => { - void treatmentCatalogApi - .list('appointment') - .then((r) => setTreatmentCatalog(r.data ?? [])) - .catch(() => {}); - }, []); - - function handleSlotClick(startMinute: number, providerUserId: string, providerName: string) { - if (!canManageAppointments) { - return; - } - if (isViewingPastDay) { - toast.showInfo(t('infoPastViewOnly')); - return; - } - setBookingStartMinute(startMinute); - setBookingProviderId(providerUserId); - setBookingProviderName(providerName); - setEditingAppointmentId(null); - setBookingOpen(true); - } - - function handleAppointmentClick(appointment: AppointmentRecord) { - if (!canManageAppointments) { - return; - } - if (isViewingPastDay) { - toast.showInfo(t('infoPastViewOnly')); - return; - } - const provider = providers.find((p) => p.userId === appointment.providerUserId); - const start = new Date(appointment.startAt); - setBookingStartMinute(start.getHours() * 60 + start.getMinutes()); - setBookingProviderId(appointment.providerUserId); - setBookingProviderName(provider?.name ?? bookingProviderName); - setEditingAppointmentId(appointment.id); - setBookingOpen(true); - } - - function handleAppointmentOutsideHours(appointment: AppointmentRecord) { - toast.showError(t('errorOutsideHours')); - } - - async function handleSaveAppointment(payload: { - patientId: string; - providerUserId: string; - startAt: string; - endAt: string; - purpose: AppointmentPurpose; - }) { - setSavingAppointment(true); - toast.setError(''); - try { - if (activeEditingAppointment) { - await appointmentsApi.update(activeEditingAppointment.id, payload); - } else { - await appointmentsApi.create(payload); - } - setBookingOpen(false); - setEditingAppointmentId(null); - toast.showSuccess(activeEditingAppointment ? t('successUpdated') : t('successSaved')); - await loadSchedule(); - } catch (err: unknown) { - toast.showError( - getUserFacingError( - err, - tErrors, - activeEditingAppointment ? t('errorUpdate') : t('errorSave'), - ), - ); - } finally { - setSavingAppointment(false); - } - } - - async function handleDeleteEditingAppointment() { - if (!activeEditingAppointment) { - return; - } - if (!window.confirm(t('confirmRemove'))) { - return; - } - setDeletingAppointment(true); - toast.setError(''); - try { - await appointmentsApi.remove(activeEditingAppointment.id); - setBookingOpen(false); - setEditingAppointmentId(null); - toast.showSuccess(t('successRemoved')); - await loadSchedule(); - } catch (err: unknown) { - toast.showError(getUserFacingError(err, tErrors, t('errorDelete'))); - } finally { - setDeletingAppointment(false); - } - } - - return ( -
-
-

{t('title')}

-

{t('subtitle')}

-
- -
-
-
- -
- -
- -
- - -
- setScheduleDate(startOfLocalDay(d))} - /> - {loadingSchedule && ( -

{t('loadingSchedule')}

- )} -
- - handleSlotClick(startMinute, uid, name)} - onAppointmentClick={(apt) => handleAppointmentClick(apt)} - onAppointmentOutsideHours={(apt) => handleAppointmentOutsideHours(apt)} - /> -
-
- - { - setBookingOpen(false); - setEditingAppointmentId(null); - }} - onSubmit={handleSaveAppointment} - loading={savingAppointment} - canDelete={canManageAppointments && !isViewingPastDay && !!activeEditingAppointment} - onDelete={() => void handleDeleteEditingAppointment()} - deleting={deletingAppointment} - /> -
- ); -} +'use client'; + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { useRouter } from '@/i18n/navigation'; +import { appointmentsApi } from '@/lib/api/appointments'; +import { treatmentCatalogApi } from '@/lib/api/treatment-catalog'; +import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; +import { useAuth } from '@/lib/hooks/useAuth'; +import { usePatientSearchQuery } from '@/lib/hooks/usePatientSearchQuery'; +import { canEditAppointments, hasPermission } from '@/components/shared/permissions'; +import type { AppointmentColumnProvider, AppointmentRecord } from '@/types/appointment'; +import type { Patient } from '@/types/patient'; +import { PatientSummaryCard } from '@/components/ui/patient/PatientSummaryCard'; +import { PatientSearchCombobox } from '@/components/ui/patient/PatientSearchCombobox'; +import { AppointmentBookingModal } from '@/components/ui/appointments/AppointmentBookingModal'; +import { AppointmentScheduleGrid } from '@/components/ui/appointments/AppointmentScheduleGrid'; +import { AppointmentScheduleLegend } from '@/components/ui/appointments/AppointmentScheduleLegend'; +import { ScheduleDayPicker } from '@/components/ui/shared/ScheduleDayPicker'; +import { useToast } from '@/lib/hooks/useToast'; +import type { AppointmentPurpose } from '@/types/appointment'; +import { getUserFacingError } from '@/components/shared/formatApiError'; +import { compareLocalDayStart, getLocalDayIsoRange, startOfLocalDay } from '@/components/appointments/appointmentTime'; + +export function AppointmentsPage() { + const t = useTranslations('appointments'); + const tErrors = useTranslations('errors'); + const router = useRouter(); + const { currentOrganization } = useAuth(); + const [scheduleDate, setScheduleDate] = useState(() => startOfLocalDay(new Date())); + + const [providers, setProviders] = useState([]); + const [appointments, setAppointments] = useState([]); + const [treatmentCatalog, setTreatmentCatalog] = useState([]); + const [loadingSchedule, setLoadingSchedule] = useState(false); + const toast = useToast(); + + const { search, setSearch, patients, loading: loadingPatients } = usePatientSearchQuery(); + const [selectedPatient, setSelectedPatient] = useState(); + + const [bookingOpen, setBookingOpen] = useState(false); + const [bookingStartMinute, setBookingStartMinute] = useState(9 * 60); + const [bookingProviderId, setBookingProviderId] = useState(null); + const [bookingProviderName, setBookingProviderName] = useState(''); + const [editingAppointmentId, setEditingAppointmentId] = useState(null); + const [savingAppointment, setSavingAppointment] = useState(false); + const [deletingAppointment, setDeletingAppointment] = useState(false); + + const canManageAppointments = canEditAppointments(currentOrganization); + const canEditPatients = hasPermission(currentOrganization, 'TAB_PATIENTS_EDIT'); + + const todayStart = useMemo(() => startOfLocalDay(new Date()), []); + const isViewingPastDay = useMemo( + () => compareLocalDayStart(scheduleDate, todayStart) < 0, + [scheduleDate, todayStart], + ); + const activeEditingAppointment = useMemo( + () => appointments.find((a) => a.id === editingAppointmentId) ?? null, + [appointments, editingAppointmentId], + ); + + const scheduleLoadGen = useRef(0); + + const sortedPatients = useMemo( + () => + [...patients].sort((a, b) => + `${a.firstName} ${a.lastName}`.localeCompare(`${b.firstName} ${b.lastName}`), + ), + [patients], + ); + + const navigateToAddPatient = useCallback(() => { + if (!canEditPatients) { + return; + } + router.push('/patients?action=create'); + }, [canEditPatients, router]); + + const loadSchedule = useCallback(async () => { + if (!currentOrganization?.id) { + return; + } + const gen = ++scheduleLoadGen.current; + setLoadingSchedule(true); + toast.setError(''); + try { + const range = getLocalDayIsoRange(scheduleDate); + const [pRes, aRes] = await Promise.all([ + appointmentsApi.columnProviders(scheduleDate), + appointmentsApi.list(range), + ]); + if (gen !== scheduleLoadGen.current) { + return; + } + setProviders(pRes.data ?? []); + setAppointments(aRes.data ?? []); + } catch (err: unknown) { + if (gen !== scheduleLoadGen.current) { + return; + } + toast.showError(getUserFacingError(err, tErrors, t('errorLoadSchedule'))); + } finally { + if (gen === scheduleLoadGen.current) { + setLoadingSchedule(false); + } + } + }, [currentOrganization?.id, scheduleDate, t, tErrors, toast]); + + useEffect(() => { + void loadSchedule(); + }, [loadSchedule]); + + useEffect(() => { + void treatmentCatalogApi + .list('appointment') + .then((r) => setTreatmentCatalog(r.data ?? [])) + .catch(() => {}); + }, []); + + function handleSlotClick(startMinute: number, providerUserId: string, providerName: string) { + if (!canManageAppointments) { + return; + } + if (isViewingPastDay) { + toast.showInfo(t('infoPastViewOnly')); + return; + } + setBookingStartMinute(startMinute); + setBookingProviderId(providerUserId); + setBookingProviderName(providerName); + setEditingAppointmentId(null); + setBookingOpen(true); + } + + function handleAppointmentClick(appointment: AppointmentRecord) { + if (!canManageAppointments) { + return; + } + if (isViewingPastDay) { + toast.showInfo(t('infoPastViewOnly')); + return; + } + const provider = providers.find((p) => p.userId === appointment.providerUserId); + const start = new Date(appointment.startAt); + setBookingStartMinute(start.getHours() * 60 + start.getMinutes()); + setBookingProviderId(appointment.providerUserId); + setBookingProviderName(provider?.name ?? bookingProviderName); + setEditingAppointmentId(appointment.id); + setBookingOpen(true); + } + + function handleAppointmentOutsideHours(appointment: AppointmentRecord) { + toast.showError(t('errorOutsideHours')); + } + + async function handleSaveAppointment(payload: { + patientId: string; + providerUserId: string; + startAt: string; + endAt: string; + purpose: AppointmentPurpose; + }) { + setSavingAppointment(true); + toast.setError(''); + try { + if (activeEditingAppointment) { + await appointmentsApi.update(activeEditingAppointment.id, payload); + } else { + await appointmentsApi.create(payload); + } + setBookingOpen(false); + setEditingAppointmentId(null); + toast.showSuccess(activeEditingAppointment ? t('successUpdated') : t('successSaved')); + await loadSchedule(); + } catch (err: unknown) { + toast.showError( + getUserFacingError( + err, + tErrors, + activeEditingAppointment ? t('errorUpdate') : t('errorSave'), + ), + ); + } finally { + setSavingAppointment(false); + } + } + + async function handleDeleteEditingAppointment() { + if (!activeEditingAppointment) { + return; + } + if (!window.confirm(t('confirmRemove'))) { + return; + } + setDeletingAppointment(true); + toast.setError(''); + try { + await appointmentsApi.remove(activeEditingAppointment.id); + setBookingOpen(false); + setEditingAppointmentId(null); + toast.showSuccess(t('successRemoved')); + await loadSchedule(); + } catch (err: unknown) { + toast.showError(getUserFacingError(err, tErrors, t('errorDelete'))); + } finally { + setDeletingAppointment(false); + } + } + + return ( +
+
+

{t('title')}

+

{t('subtitle')}

+
+ +
+
+
+ +
+ +
+ +
+ + +
+ setScheduleDate(startOfLocalDay(d))} + /> + {loadingSchedule && ( +

{t('loadingSchedule')}

+ )} +
+ + handleSlotClick(startMinute, uid, name)} + onAppointmentClick={(apt) => handleAppointmentClick(apt)} + onAppointmentOutsideHours={(apt) => handleAppointmentOutsideHours(apt)} + /> +
+
+ + { + setBookingOpen(false); + setEditingAppointmentId(null); + }} + onSubmit={handleSaveAppointment} + loading={savingAppointment} + canDelete={ + canManageAppointments && + !isViewingPastDay && + !!activeEditingAppointment && + !activeEditingAppointment.hasTreatment + } + onDelete={() => void handleDeleteEditingAppointment()} + deleting={deletingAppointment} + /> +
+ ); +} -- 2.53.0.windows.1 From cf07b4d8a8ea0bf965e0d0084a4d4fa73cfbc117 Mon Sep 17 00:00:00 2001 From: Admin Date: Thu, 16 Jul 2026 22:45:37 +0330 Subject: [PATCH 02/13] improvement: delete action added for unsent treatment details. some edit controls added to details and shipments. --- backend/src/common/errors/error-codes.ts | 1 + .../modules/treatments/treatments.service.ts | 21 +++++++++++ frontend/messages/en.json | 2 ++ frontend/messages/fa.json | 2 ++ frontend/messages/nl.json | 2 ++ .../ui/treatment/TreatmentDetailsEditor.tsx | 19 ++++++++++ .../ui/treatment/TreatmentWorkspace.tsx | 36 ++++++++++++++++++- 7 files changed, 82 insertions(+), 1 deletion(-) diff --git a/backend/src/common/errors/error-codes.ts b/backend/src/common/errors/error-codes.ts index e1c6608..231de83 100644 --- a/backend/src/common/errors/error-codes.ts +++ b/backend/src/common/errors/error-codes.ts @@ -64,6 +64,7 @@ export const ErrorCode = { CONFLICT_FUTURE_APPOINTMENTS: 'CONFLICT_FUTURE_APPOINTMENTS', APPOINTMENT_PATIENT_LOCKED: 'APPOINTMENT_PATIENT_LOCKED', APPOINTMENT_HAS_TREATMENT: 'APPOINTMENT_HAS_TREATMENT', + TREATMENT_DETAIL_SENT: 'TREATMENT_DETAIL_SENT', BAD_REQUEST: 'BAD_REQUEST', INTERNAL_ERROR: 'INTERNAL_ERROR', } as const; diff --git a/backend/src/modules/treatments/treatments.service.ts b/backend/src/modules/treatments/treatments.service.ts index de5ba9a..2bf2de8 100644 --- a/backend/src/modules/treatments/treatments.service.ts +++ b/backend/src/modules/treatments/treatments.service.ts @@ -1,9 +1,11 @@ import { BadRequestException, ForbiddenException, + HttpStatus, Injectable, NotFoundException, } from '@nestjs/common'; +import { AppException, ErrorCode } from '../../common/errors'; import { LabCaseActivityType, LabTaskStatus, LinkStatus, Prisma } from '@prisma/client'; import { createReadStream, existsSync, mkdirSync } from 'fs'; import { join } from 'path'; @@ -908,6 +910,25 @@ export class TreatmentsService { throw new BadRequestException('At least one file is required'); } + const existingDetail = await this.prisma.treatmentDetail.findFirst({ + where: { + clientKey: detailClientKey, + treatment: { + appointmentId, + organizationId, + ...treatmentProviderScopeWhere(actorUserId), + }, + }, + select: { + labCaseLink: { + select: { labCase: { select: { sentAt: true } } }, + }, + }, + }); + if (existingDetail?.labCaseLink?.labCase.sentAt) { + throw new AppException(ErrorCode.TREATMENT_DETAIL_SENT, HttpStatus.CONFLICT); + } + const orgDir = join(this.uploadRoot, organizationId); mkdirSync(orgDir, { recursive: true }); diff --git a/frontend/messages/en.json b/frontend/messages/en.json index ae24ff6..a9fcf8c 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -660,6 +660,7 @@ "detailsTitle": "Treatment details", "detailsSubtitle": "Plan teeth, type, notes, and attachments for each detail line.", "addDetail": "Add detail", + "confirmRemoveDetail": "Remove this treatment detail?", "detailLabel": "Detail {n}", "detailSentBadge": "sent", "detailLockedInShipment": "This detail was sent to a lab and can no longer be edited.", @@ -1015,6 +1016,7 @@ "CONFLICT_FUTURE_APPOINTMENTS": "You cannot stop participating in treatments while you have future appointments. Reassign or cancel them first.", "APPOINTMENT_PATIENT_LOCKED": "Cannot change the patient while a treatment is linked to this appointment.", "APPOINTMENT_HAS_TREATMENT": "This appointment cannot be deleted because a treatment is linked to it.", + "TREATMENT_DETAIL_SENT": "This detail was sent to a lab and can no longer be changed.", "BAD_REQUEST": "The request could not be processed.", "INTERNAL_ERROR": "Something went wrong on our end. Please try again later." } diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index 0f0e74b..3b48635 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -661,6 +661,7 @@ "detailsTitle": "جزئیات درمان", "detailsSubtitle": "دندان‌ها، نوع، یادداشت و پیوست‌ها را برای هر خط جزئیات برنامه‌ریزی کنید.", "addDetail": "افزودن جزئیات", + "confirmRemoveDetail": "این جزئیات درمان حذف شود؟", "detailLabel": "جزئیات {n}", "detailSentBadge": "ارسال‌شده", "detailLockedInShipment": "این جزئیات به لابراتوار ارسال شده و دیگر قابل ویرایش نیست.", @@ -1016,6 +1017,7 @@ "CONFLICT_FUTURE_APPOINTMENTS": "تا وقتی نوبت‌های آینده دارید نمی‌توانید مشارکت در درمان را متوقف کنید. ابتدا آن‌ها را لغو یا واگذار کنید.", "APPOINTMENT_PATIENT_LOCKED": "تا وقتی درمانی به این نوبت متصل است، امکان تغییر بیمار وجود ندارد.", "APPOINTMENT_HAS_TREATMENT": "به‌دلیل وجود درمان مرتبط با این نوبت، امکان حذف آن وجود ندارد.", + "TREATMENT_DETAIL_SENT": "این جزئیات به لابراتوار ارسال شده و دیگر قابل تغییر نیست.", "BAD_REQUEST": "درخواست قابل پردازش نبود.", "INTERNAL_ERROR": "مشکلی در سرور رخ داد. لطفاً بعداً تلاش کنید." } diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index d3f94cd..426e9b4 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -660,6 +660,7 @@ "detailsTitle": "Behandeldetails", "detailsSubtitle": "Plan tanden, type, notities en bijlagen per detailregel.", "addDetail": "Detail toevoegen", + "confirmRemoveDetail": "Dit behandelingsdetail verwijderen?", "detailLabel": "Detail {n}", "detailSentBadge": "verzonden", "detailLockedInShipment": "Dit detail is naar het lab verzonden en kan niet meer worden bewerkt.", @@ -1015,6 +1016,7 @@ "CONFLICT_FUTURE_APPOINTMENTS": "U kunt niet stoppen met deelnemen aan behandelingen zolang u toekomstige afspraken hebt. Wijs ze eerst opnieuw toe of annuleer ze.", "APPOINTMENT_PATIENT_LOCKED": "De patiënt kan niet worden gewijzigd zolang er een behandeling aan deze afspraak is gekoppeld.", "APPOINTMENT_HAS_TREATMENT": "Deze afspraak kan niet worden verwijderd omdat er een behandeling aan is gekoppeld.", + "TREATMENT_DETAIL_SENT": "Dit detail is naar het lab verzonden en kan niet meer worden gewijzigd.", "BAD_REQUEST": "Het verzoek kon niet worden verwerkt.", "INTERNAL_ERROR": "Er is iets misgegaan aan onze kant. Probeer het later opnieuw." } diff --git a/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx b/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx index f3206f9..81735bd 100644 --- a/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx +++ b/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx @@ -32,6 +32,7 @@ interface TreatmentDetailsEditorProps { saveStatus: 'idle' | 'dirty' | 'saving' | 'saved' | 'error'; uploadBusy: boolean; onAddDetail: () => void; + onRemoveDetail: () => void; onUploadFiles: (files: FileList | null) => void; } @@ -48,9 +49,11 @@ export function TreatmentDetailsEditor({ saveStatus, uploadBusy, onAddDetail, + onRemoveDetail, onUploadFiles, }: TreatmentDetailsEditorProps) { const t = useTranslations('treatment'); + const tCommon = useTranslations('common'); const attachmentInputRef = useRef(null); const activeDetail = details.find((d) => d.clientId === activeDetailId) ?? details[0]; @@ -58,6 +61,7 @@ export function TreatmentDetailsEditor({ const locked = isDetailLocked(activeDetail); const readOnly = disabled || locked; + const canRemoveDetail = canEdit && !disabled && !locked && details.length > 1; const treatmentTypeTextColor = isDetailTypeSelected(activeDetail) ? treatmentTypeColor( activeDetail.treatmentType, @@ -206,6 +210,21 @@ export function TreatmentDetailsEditor({ )} + + {canRemoveDetail ? ( +
+ +
+ ) : null} {canEdit && saveStatus !== 'idle' && ( diff --git a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx index 310252e..330e7a5 100644 --- a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx +++ b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx @@ -1249,6 +1249,8 @@ export function TreatmentWorkspace({ const uploadForDetail = useCallback( async (detailClientId: string, files: FileList | File[]) => { if (!canEditTreatmentForDay || !selectedAppointment) return; + const target = detailsRef.current.find((d) => d.clientId === detailClientId); + if (target && isDetailLocked(target)) return; const list = files instanceof FileList ? Array.from(files) : files; if (!list.length) return; @@ -1273,7 +1275,7 @@ export function TreatmentWorkspace({ setUploadBusyDetailId(null); } }, - [canEditTreatmentForDay, selectedAppointment, showSuccess, showError, t], + [canEditTreatmentForDay, isDetailLocked, selectedAppointment, showSuccess, showError, t, tErrors], ); const persistLabCases = useCallback( @@ -1373,9 +1375,40 @@ export function TreatmentWorkspace({ selectedAppointment, showError, t, + tErrors, ], ); + const handleRemoveActiveDetail = useCallback(() => { + if (!canEditTreatmentForDay) return; + const idx = details.findIndex((d) => d.clientId === activeDetailId); + if (idx < 0) return; + const active = details[idx]; + if (!active || isDetailLocked(active) || details.length <= 1) return; + if (!window.confirm(t('confirmRemoveDetail'))) return; + + const removedId = active.clientId; + const nextDetails = details.filter((d) => d.clientId !== removedId); + const nextActive = + nextDetails[Math.min(idx, nextDetails.length - 1)]?.clientId ?? nextDetails[0]?.clientId; + setDetails(nextDetails); + if (nextActive) setActiveDetailId(nextActive); + + if (labCaseDrafts.some((lc) => lc.detailClientId === removedId)) { + handleLabCasesChange( + withoutEmptyLabCaseDrafts(labCaseDrafts.filter((lc) => lc.detailClientId !== removedId)), + ); + } + }, [ + activeDetailId, + canEditTreatmentForDay, + details, + handleLabCasesChange, + isDetailLocked, + labCaseDrafts, + t, + ]); + const handleAddLabCase = useCallback(async () => { if (!canEditTreatmentForDay || !selectedAppointment) return; @@ -1779,6 +1812,7 @@ export function TreatmentWorkspace({ setDetails((prev) => [...prev, next]); setActiveDetailId(next.clientId); }} + onRemoveDetail={handleRemoveActiveDetail} onUploadFiles={(files) => void uploadForDetail(activeDetailId, files ?? [])} /> -- 2.53.0.windows.1 From 68fc9d5d6d575add3481e400ddcc3b7a4aa1c67a Mon Sep 17 00:00:00 2001 From: Admin Date: Fri, 17 Jul 2026 00:39:32 +0330 Subject: [PATCH 03/13] improvement: treatment plan turned into a wizard. shift+click control added to FDI tooth chart for cunnected prosthesises. --- .cursor/rules/appointments.mdc | 12 + .cursor/rules/treatment-workspace.mdc | 34 +- .cursor/skills/treatment-workspace/SKILL.md | 54 ++- AGENTS.md | 11 +- .../migration.sql | 14 + backend/prisma/schema.prisma | 8 +- backend/src/modules/cases/cases.service.ts | 45 +- .../modules/cases/lab-case-access.service.ts | 37 +- .../cases/lab-case-task.generator.spec.ts | 46 ++ .../modules/cases/lab-case-task.generator.ts | 39 +- .../modules/treatments/dto/treatment.dto.ts | 25 + .../src/modules/treatments/treatment.utils.ts | 36 ++ .../modules/treatments/treatments.service.ts | 99 +++- frontend/messages/en.json | 14 +- frontend/messages/fa.json | 14 +- frontend/messages/nl.json | 14 +- .../treatment/toothSelectionGroups.ts | 189 ++++++++ .../ui/lab/LabCaseProsthesisGroupsList.tsx | 8 +- .../components/ui/shared/WizardStepper.tsx | 100 ++++ .../ui/treatment/ConnectedSelectionBadge.tsx | 29 ++ .../components/ui/treatment/FdiToothChart.tsx | 37 +- .../ui/treatment/LabCasesDispatchPanel.tsx | 284 +++++++----- .../ui/treatment/TreatmentDetailsEditor.tsx | 332 ++++++++------ .../ui/treatment/TreatmentWorkspace.tsx | 434 +++++++++++++----- frontend/src/lib/api/lab-case-access.ts | 2 + frontend/src/types/cases.ts | 2 + frontend/src/types/lab-case-activity.ts | 2 + frontend/src/types/treatment.ts | 17 + 28 files changed, 1461 insertions(+), 477 deletions(-) create mode 100644 .cursor/rules/appointments.mdc create mode 100644 backend/prisma/migrations/20260716120000_tooth_selection_groups/migration.sql create mode 100644 frontend/src/components/treatment/toothSelectionGroups.ts create mode 100644 frontend/src/components/ui/shared/WizardStepper.tsx create mode 100644 frontend/src/components/ui/treatment/ConnectedSelectionBadge.tsx diff --git a/.cursor/rules/appointments.mdc b/.cursor/rules/appointments.mdc new file mode 100644 index 0000000..1a64402 --- /dev/null +++ b/.cursor/rules/appointments.mdc @@ -0,0 +1,12 @@ +--- +description: Clinic appointments — delete/patient locks when treatment linked +globs: frontend/src/components/ui/appointments/**,frontend/src/components/appointments/**,backend/src/modules/appointments/** +alwaysApply: false +--- + +# Appointments + +- List includes `hasTreatment` when a `Treatment` row is linked (`appointmentId`). +- **Patient change** blocked while linked → `APPOINTMENT_PATIENT_LOCKED` (UI: `patientLockedHint`). +- **Delete** blocked while linked → `APPOINTMENT_HAS_TREATMENT` (hide delete + `deleteBlockedHint`). Empty appointments (no treatment yet) remain deletable. +- Prisma: `Treatment.appointmentId` is `onDelete: SetNull` — deleting an appointment does **not** cascade-delete treatments/cases/tasks; do not rely on cascade for cleanup. diff --git a/.cursor/rules/treatment-workspace.mdc b/.cursor/rules/treatment-workspace.mdc index fbe4e17..08c6bd7 100644 --- a/.cursor/rules/treatment-workspace.mdc +++ b/.cursor/rules/treatment-workspace.mdc @@ -1,40 +1,22 @@ --- - description: Treatment workspace — preview vs form, history, load flow, lab dispatch - globs: frontend/src/components/ui/treatment/**,frontend/src/components/treatment/**,frontend/src/components/shared/treatmentSelection.ts,frontend/src/components/shared/scrollWithinMain.ts,backend/src/modules/treatments/** - alwaysApply: false - --- - - # Treatment workspace - - - **Browse mode** (`selectedPreviewId` set): preview only; banner + **Load into workspace**; form unchanged until load. - - **Current draft** preview: heading “Current draft”; no load button while editing live. - +- **Entry wizard:** Teeth → Content → Lab via `WizardStepper` (not detail chips). Lab step only for lab-dependent (prosthesis) active detail. Appointment purpose does not gate type or Next. +- **Tooth groups:** Shift+same-arch range = connected; click in connected span → collapse to that single only. `toothSelectionGroups.ts`; prune lab `toothProsthesis` on change. +- **Detail chrome:** chips + Add at top; **Remove** = trash on chip (unsent, >1 detail; disabled when day-locked / no edit / uploading). No delete in Content step. - **Lab dispatch attention:** `LabDispatchAttentionPanel` — unsent lab-dependent details; quick jump to dispatch. - - **History API:** patient-scoped; non-owners filtered by provider on treatment or appointment; org owners see all. - -- **History filters (client-side):** `PastTreatmentsPanel` — “Not shipped to lab” + single date; helpers in `treatmentHistoryFilters.ts`. No new fetch params. - -- **Lab shipment block:** prosthesis/lab-dependent detail with no teeth saves but cannot ship — `LabShipmentBlockedNotice`, inline banner, toast on add shipment. - -- **Lab case comments:** `DetailLabCaseCommentsSection` under detail when sent and lab tasks not all complete (`taskProgress` from API). - -- **Today checkbox:** unchecked when viewing a non-today day; checking Today unlocks selection and auto-picks nearest appointment. - -- **Lab search:** `LinkedOrganizationSearchCombobox` — select from results; invite lab via `/organizations?action=invite-lab` when permitted. - -- **Scroll:** use `scrollWithinMainScrollContainer`; shared `Checkbox` only. - - +- **History filters (client-side):** `PastTreatmentsPanel` — “Not shipped to lab” + single date; helpers in `treatmentHistoryFilters.ts`. +- **Lab shipment block:** lab-dependent detail with no teeth saves but cannot ship — `LabShipmentBlockedNotice`, inline banner, toast on add shipment. +- **Edit gating:** `canEditTreatmentForDay` = permission + appointment + not past day + `live` mode. Sent detail locks that line; Add still OK same day. Upload rejects sent (`TREATMENT_DETAIL_SENT`). +- **Lab search:** `LinkedOrganizationSearchCombobox`; invite lab via `/organizations?action=invite-lab` when permitted. +- **Scroll:** `scrollWithinMainScrollContainer`; shared `Checkbox` only. Full map: `.cursor/skills/treatment-workspace/SKILL.md` - diff --git a/.cursor/skills/treatment-workspace/SKILL.md b/.cursor/skills/treatment-workspace/SKILL.md index ddcf835..ade77c9 100644 --- a/.cursor/skills/treatment-workspace/SKILL.md +++ b/.cursor/skills/treatment-workspace/SKILL.md @@ -2,7 +2,7 @@ name: dyolink-treatment-workspace -description: Treatment tab workspace — appointments strip, preview vs form, history, load flow, draft autosave, lab dispatch. Use when changing treatment UX, preview/history, or lab dispatch in TreatmentWorkspace. +description: Treatment tab workspace — appointments strip, preview vs form, history, load flow, draft autosave, entry wizard, connected teeth, lab dispatch. Use when changing treatment UX, preview/history, or lab dispatch in TreatmentWorkspace. --- @@ -28,7 +28,37 @@ Thin route: `app/[locale]/(dashboard)/treatment/page.tsx` (supports `?appointmen 3. **Treatment history** — `PastTreatmentsPanel.tsx` (past saved plans for patient; **client-side** filters in `treatmentHistoryFilters.ts`) -4. **Editor** — `TreatmentDetailsEditor.tsx`, `FdiToothChart.tsx`, `LabCasesDispatchPanel.tsx` +4. **Editor** — detail chips + Add (`TreatmentDetailsEditor` chrome); entry wizard (`WizardStepper`); step panels: `FdiToothChart`, Content fields, `LabCasesDispatchPanel` + + + +## Entry wizard (Teeth / Content / Lab) + + + +Right-column entry uses `WizardStepper` (`components/ui/shared/WizardStepper.tsx`) — numbered nodes + connector rail. **Do not** reuse detail-chip tab styling for steps. + +| Step | UI | Notes | +|------|-----|--------| +| **Teeth** | `FdiToothChart` | Shift+click range; connected dots | +| **Content** | `TreatmentDetailsEditor` fields only (`showChrome={false}`) | Type / notes / attachments — **no delete button** | +| **Lab** | `LabCasesDispatchPanel` | Shown in stepper **only** when active detail type is lab-dependent (`labDependentCodes`) | + +- Detail type may differ from appointment purpose (purpose only defaults new details). +- Next/Back navigate visible steps; leaving prosthesis while on Lab returns to Content. + + + +## Tooth selection groups + + + +Helpers: `frontend/src/components/treatment/toothSelectionGroups.ts`. Persisted as `toothSelectionGroups` on the detail; lab tasks group by `selectionGroupId`. + +- **Plain click:** add/remove single; if tooth is in a **connected** group → drop the whole span and keep **only** that tooth as a single (no peel / no connected+single pair for the same span). +- **Shift+click:** inclusive same-arch range between prior anchor and target; replaces overlapping groups. Prevent browser selection artifacts (`select-none`, Shift `preventDefault` on mousedown). +- On group change, prune/remap `labCase.toothProsthesis` via `pruneToothProsthesisForGroups`. +- Connected UI label: `ConnectedSelectionBadge` (shared `Badge` + primary tint) in dispatch + lab case lists. @@ -161,17 +191,27 @@ Draft writes require provider match (`ensureAppointmentProvider`) unless org own ## Edit gating - - ```typescript - canEditTreatmentForDay = canEdit && selectedAppointment && !isViewingPastDay && workspaceMode === 'live' - ``` +Past day or `historical` workspace mode freezes the treatment form + most lab-dispatch fields. +### Per-detail sent lock + +`isDetailLocked` = linked lab case has `sentAt`. Locked details: type, comment, teeth, attachments are read-only (UI + draft save skips updates/deletes; attachment upload returns `TREATMENT_DETAIL_SENT`). + +### Add / remove details + +- **Add detail** stays enabled whenever `canEditTreatmentForDay` — even if sibling details are already sent. +- **Remove detail:** trash icon on each **detail chip** in chrome (`TreatmentDetailsEditor` `showChrome`). Same gates as before: only when `details.length > 1`; disabled when `!canEdit`, day/workspace `disabled`, detail sent (`isDetailLocked`), or `uploadBusy`. Confirm via `confirmRemoveDetail`. Drop linked unsent lab drafts with the detail. **Do not** put a delete control in the Content wizard step. + +### Sent lab shipment fields (vs detail) + +After send, destination / prosthesis / shipment attachments are frozen. **Due date** stays editable until all lab tasks complete (UI still respects day/mode `disabled`). **Comments** stay editable until tasks complete and intentionally ignore the day/mode gate. + +Full map helpers: `treatmentDetailRules.ts`, `LabCasesDispatchPanel.tsx`. -## Optional fields diff --git a/AGENTS.md b/AGENTS.md index e248ce1..0f24565 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,13 +42,22 @@ frontend/src/ **Treatment tab:** Preview and editable form are **separate** until the user clicks **Load into workspace** on a history item. See `.cursor/skills/treatment-workspace/SKILL.md` before changing that flow. +**Treatment edit / details (quick ref):** +- Day/mode gate: editable only for live draft on today/future (`canEditTreatmentForDay`). Past day / historical load → read-only form. +- Sent-to-lab detail locks that line; **Add detail** still OK same day; **Remove detail** = trash icon on each detail chip (not in the wizard Content step) — only unsent and not the last line. Attachment upload blocked when sent (`TREATMENT_DETAIL_SENT`). +- **Entry wizard:** `WizardStepper` — Teeth → Content → Lab; Lab step only when active detail type is lab-dependent (prosthesis). Detail chips ≠ wizard chrome (chips stay bordered tabs; stepper is numbered rail). +- **Tooth selection:** Shift+click same-arch range = connected group; plain click on a connected tooth collapses the whole span to that single tooth (no peel). Helpers: `toothSelectionGroups.ts`. Connected label: `ConnectedSelectionBadge` (shared `Badge` + primary tint). + **Treatment lab rules (quick ref):** - Lab-dependent details (e.g. prosthesis) **without teeth** can save but **cannot ship** — show `LabShipmentBlockedNotice` + inline banner; toast on dispatch add. +- Detail treatment type need **not** match appointment purpose — purpose only pre-fills new details. - **History filters** are client-side only (`treatmentHistoryFilters.ts`): “Not shipped to lab” + single date on already-fetched patient history; includes live current draft when filtering. - **Lab shipments rail**: unified list with scope toggle **This patient** vs **All updates** (unread across org for **this clinician's cases only**, includes patient name). - **Unread semantics**: Treatment tab badge = count of unread cases **for the user's own treatment plans** (per-case read cursor) and clears when a case is opened/marked read (not on tab visit). - **Lab shipment progress + comments**: shown in **Lab dispatch panel** for the active shipment; expanding activity / opening comments marks that case read. +**Appointments (quick ref):** Do not delete (or change patient) when `hasTreatment`; codes `APPOINTMENT_HAS_TREATMENT` / `APPOINTMENT_PATIENT_LOCKED`. Appointment delete does not cascade-delete treatments. See `.cursor/rules/appointments.mdc`. + **Lab Tasks tab:** Newest case first; steps ordered 1→N; case grouping when sorted by date; `stepCompleted` filter; prosthesis colors from catalog; task assignment in **Cases** (compact row: status + assignee + last update); on **Tasks**, all staff see every task but only assignee (or unassigned pool) can change status — others see “Assigned to {name}” instead of the status dropdown; **case due dates** set/edited in clinic Treatment lab dispatch, shown on lab Cases/Tasks with overdue filter + sort; **mobile:** larger task status controls, sticky case header when grouped; **tab badges:** `LabCaseActivity` + `GET /notifications/tab-counts` (lab Cases/Tasks split, clinic Treatment) — see `.cursor/skills/lab-tasks/SKILL.md` and `.cursor/skills/lab-notifications/SKILL.md`. **Lab Cases tab:** Filter by **prosthesis type** (not treatment type); auto-select newest case on open; list cards use `LabCaseProsthesisGroupsList` (colored type + teeth, shared with Treatment rail). Deep link: `?caseId=`, `?clinicOrganizationId=`. **Share link:** QR + URL on sent cases (attachment left, QR right); opens `/lab-case/[token]` focus page. See `.cursor/skills/lab-cases/SKILL.md` and `.cursor/skills/lab-case-share-link/SKILL.md`. @@ -82,7 +91,7 @@ Errors: `AppException` + `ErrorCode` → frontend `getUserFacingError()`. Never | Skill | When to use | |-------|-------------| | `.cursor/skills/add-feature/` | New tab, API module, or end-to-end feature | -| `.cursor/skills/treatment-workspace/` | Treatment tab: preview vs form, history, load flow, drafts | +| `.cursor/skills/treatment-workspace/` | Treatment tab: preview vs form, history, load flow, drafts, entry wizard, connected teeth | | `.cursor/skills/lab-tasks/` | Lab Tasks tab: sort, case grouping, step-completed filter, prosthesis colors | | `.cursor/skills/lab-cases/` | Lab Cases tab: prosthesis filter, auto-select, list cards, assignment | | `.cursor/skills/lab-case-share-link/` | Case QR share link: access token, focus page, auth redirect, access rules | diff --git a/backend/prisma/migrations/20260716120000_tooth_selection_groups/migration.sql b/backend/prisma/migrations/20260716120000_tooth_selection_groups/migration.sql new file mode 100644 index 0000000..f7d82ed --- /dev/null +++ b/backend/prisma/migrations/20260716120000_tooth_selection_groups/migration.sql @@ -0,0 +1,14 @@ +-- AlterTable +ALTER TABLE "treatment_details" ADD COLUMN "toothSelectionGroups" JSONB; + +-- AlterTable +ALTER TABLE "lab_case_tooth_prosthesis" ADD COLUMN "selectionGroupId" TEXT NOT NULL DEFAULT ''; + +-- AlterTable +ALTER TABLE "lab_case_tasks" ADD COLUMN "selectionGroupId" TEXT NOT NULL DEFAULT ''; + +-- DropIndex +DROP INDEX IF EXISTS "lab_case_tasks_labCaseId_treatmentDetailId_prosthesisTypeCode_stepOrder_key"; + +-- CreateIndex +CREATE UNIQUE INDEX "lab_case_tasks_labCaseId_treatmentDetailId_selectionGroupId_prosthesisTypeCode_stepOrder_key" ON "lab_case_tasks"("labCaseId", "treatmentDetailId", "selectionGroupId", "prosthesisTypeCode", "stepOrder"); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 48d1409..c01f942 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -176,6 +176,8 @@ model TreatmentDetail { sortOrder Int treatmentType String teeth Json + /** Selection groups: connected spans + singles. Shape: { groupId, kind, teeth[] }[] */ + toothSelectionGroups Json? comment String? treatment Treatment @relation(fields: [treatmentId], references: [id], onDelete: Cascade) @@ -340,6 +342,8 @@ model LabCaseToothProsthesis { treatmentDetailId String tooth String prosthesisTypeCode String + /** Links to TreatmentDetail.toothSelectionGroups[].groupId for task grouping. */ + selectionGroupId String @default("") labCase LabCase @relation(fields: [labCaseId], references: [id], onDelete: Cascade) detail TreatmentDetail @relation(fields: [treatmentDetailId], references: [id], onDelete: Cascade) @@ -355,6 +359,8 @@ model LabCaseTask { teeth Json treatmentType String prosthesisTypeCode String + /** Empty string for legacy tasks; otherwise matches selection group id. */ + selectionGroupId String @default("") workflowStepCode String stepOrder Int stepLabel String @@ -373,7 +379,7 @@ model LabCaseTask { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - @@unique([labCaseId, treatmentDetailId, prosthesisTypeCode, stepOrder]) + @@unique([labCaseId, treatmentDetailId, selectionGroupId, prosthesisTypeCode, stepOrder]) @@index([labCaseId, status]) @@index([assigneeUserId]) @@map("lab_case_tasks") diff --git a/backend/src/modules/cases/cases.service.ts b/backend/src/modules/cases/cases.service.ts index 4f7b386..0924351 100644 --- a/backend/src/modules/cases/cases.service.ts +++ b/backend/src/modules/cases/cases.service.ts @@ -660,24 +660,44 @@ export class CasesService { } private buildProsthesisGroupsFromTasks( - tasks: Array<{ prosthesisTypeCode: string; teeth: Prisma.JsonValue }>, - ): Array<{ prosthesisTypeCode: string; teeth: string[] }> { - const prosthesisByCode = new Map(); + tasks: Array<{ + prosthesisTypeCode: string; + teeth: Prisma.JsonValue; + selectionGroupId?: string; + }>, + ): Array<{ + prosthesisTypeCode: string; + teeth: string[]; + connected?: boolean; + selectionGroupId?: string; + }> { + const prosthesisByGroup = new Map< + string, + { prosthesisTypeCode: string; teeth: string[]; selectionGroupId: string } + >(); for (const task of tasks) { if (!task.prosthesisTypeCode) continue; + const selectionGroupId = task.selectionGroupId ?? ''; + const key = `${selectionGroupId}::${task.prosthesisTypeCode}`; const teeth = normalizeTaskTeeth(task.teeth); - const list = prosthesisByCode.get(task.prosthesisTypeCode) ?? []; - list.push(...teeth); - prosthesisByCode.set(task.prosthesisTypeCode, list); + const entry = prosthesisByGroup.get(key) ?? { + prosthesisTypeCode: task.prosthesisTypeCode, + teeth: [], + selectionGroupId, + }; + entry.teeth.push(...teeth); + prosthesisByGroup.set(key, entry); } - return [...prosthesisByCode.entries()] - .map(([prosthesisTypeCode, teeth]) => ({ - prosthesisTypeCode, - teeth: [...new Set(teeth)].sort((a, b) => + return [...prosthesisByGroup.values()] + .map((group) => ({ + prosthesisTypeCode: group.prosthesisTypeCode, + teeth: [...new Set(group.teeth)].sort((a, b) => a.localeCompare(b, undefined, { numeric: true }), ), + selectionGroupId: group.selectionGroupId || undefined, + connected: Boolean(group.selectionGroupId) && [...new Set(group.teeth)].length > 1, })) .sort((a, b) => a.prosthesisTypeCode.localeCompare(b.prosthesisTypeCode)); } @@ -694,12 +714,14 @@ export class CasesService { treatmentType: string; prosthesisTypeCode: string; prosthesisTypeLabel: string; + selectionGroupId: string; tasks: ReturnType[]; } >(); for (const task of tasks) { - const key = `${task.treatmentDetailId}:${task.prosthesisTypeCode}`; + const selectionGroupId = task.selectionGroupId ?? ''; + const key = `${task.treatmentDetailId}:${selectionGroupId}:${task.prosthesisTypeCode}`; const entry = groups.get(key) ?? { treatmentDetailId: task.treatmentDetailId, teeth: normalizeTaskTeeth(task.teeth), @@ -707,6 +729,7 @@ export class CasesService { prosthesisTypeCode: task.prosthesisTypeCode, prosthesisTypeLabel: prosthesisLabels.get(task.prosthesisTypeCode) ?? task.prosthesisTypeCode, + selectionGroupId, tasks: [], }; entry.tasks.push(this.mapTask(task, prosthesisLabels)); diff --git a/backend/src/modules/cases/lab-case-access.service.ts b/backend/src/modules/cases/lab-case-access.service.ts index 6ab178d..50c18d1 100644 --- a/backend/src/modules/cases/lab-case-access.service.ts +++ b/backend/src/modules/cases/lab-case-access.service.ts @@ -293,24 +293,41 @@ export class LabCaseAccessService { ) { const tasks = await this.prisma.labCaseTask.findMany({ where: { labCaseId }, - select: { prosthesisTypeCode: true, teeth: true }, + select: { prosthesisTypeCode: true, teeth: true, selectionGroupId: true }, orderBy: [{ prosthesisTypeCode: 'asc' }], }); - const byCode = new Map(); + const byGroup = new Map< + string, + { prosthesisTypeCode: string; teeth: string[]; selectionGroupId: string } + >(); for (const task of tasks) { if (!task.prosthesisTypeCode) continue; + const selectionGroupId = task.selectionGroupId ?? ''; + const key = `${selectionGroupId}::${task.prosthesisTypeCode}`; const teeth = normalizeTaskTeeth(task.teeth); - const list = byCode.get(task.prosthesisTypeCode) ?? []; - list.push(...teeth); - byCode.set(task.prosthesisTypeCode, list); + const entry = byGroup.get(key) ?? { + prosthesisTypeCode: task.prosthesisTypeCode, + teeth: [], + selectionGroupId, + }; + entry.teeth.push(...teeth); + byGroup.set(key, entry); } - return [...byCode.entries()].map(([prosthesisTypeCode, teeth]) => ({ - prosthesisTypeCode, - prosthesisTypeLabel: prosthesisLabels.get(prosthesisTypeCode) ?? prosthesisTypeCode, - teeth: [...new Set(teeth)].sort((a, b) => a.localeCompare(b, undefined, { numeric: true })), - })); + return [...byGroup.values()].map((group) => { + const teeth = [...new Set(group.teeth)].sort((a, b) => + a.localeCompare(b, undefined, { numeric: true }), + ); + return { + prosthesisTypeCode: group.prosthesisTypeCode, + prosthesisTypeLabel: + prosthesisLabels.get(group.prosthesisTypeCode) ?? group.prosthesisTypeCode, + teeth, + selectionGroupId: group.selectionGroupId || undefined, + connected: Boolean(group.selectionGroupId) && teeth.length > 1, + }; + }); } private async getMembership(userId: string, organizationId: string) { 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 31abb0e..10ba133 100644 --- a/backend/src/modules/cases/lab-case-task.generator.spec.ts +++ b/backend/src/modules/cases/lab-case-task.generator.spec.ts @@ -11,6 +11,7 @@ function buildMockTx(options: { treatmentDetailId: string; tooth: string; prosthesisTypeCode: string; + selectionGroupId?: string; treatmentType?: string; }>; prosthesisTypes: Array<{ @@ -35,9 +36,11 @@ function buildMockTx(options: { treatmentDetailId: row.treatmentDetailId, tooth: row.tooth, prosthesisTypeCode: row.prosthesisTypeCode, + selectionGroupId: row.selectionGroupId ?? '', detail: { id: row.treatmentDetailId, treatmentType: row.treatmentType ?? 'prosthesis', + toothSelectionGroups: null, }, })), ), @@ -169,6 +172,49 @@ describe('generateLabCaseTasks', () => { expect(stepCodes).toContain('printer_resin'); }); + it('keeps separate selection groups with the same prosthesis type as separate task sets', async () => { + const pfmSteps = stepsFromSeed('pfm_crown'); + const { tx, created } = buildMockTx({ + toothProsthesisRows: [ + { + treatmentDetailId: 'detail-1', + tooth: '14', + prosthesisTypeCode: 'pfm_crown', + selectionGroupId: 'connected-1', + }, + { + treatmentDetailId: 'detail-1', + tooth: '15', + prosthesisTypeCode: 'pfm_crown', + selectionGroupId: 'connected-1', + }, + { + treatmentDetailId: 'detail-1', + tooth: '21', + prosthesisTypeCode: 'pfm_crown', + selectionGroupId: 'single-21', + }, + ], + prosthesisTypes: [{ code: 'pfm_crown', steps: pfmSteps }], + }); + + const count = await generateLabCaseTasks(tx as never, 'lab-case-split', 'en'); + expect(count).toBe(pfmSteps.length * 2); + const rows = created as Array<{ + teeth: string[]; + selectionGroupId: string; + prosthesisTypeCode: string; + }>; + const connected = rows.filter((r) => r.selectionGroupId === 'connected-1'); + const single = rows.filter((r) => r.selectionGroupId === 'single-21'); + expect(connected).toHaveLength(pfmSteps.length); + expect(single).toHaveLength(pfmSteps.length); + expect(connected.every((r) => JSON.stringify(r.teeth) === JSON.stringify(['14', '15']))).toBe( + true, + ); + expect(single.every((r) => JSON.stringify(r.teeth) === JSON.stringify(['21']))).toBe(true); + }); + it('skips generation when tasks already exist', async () => { const { tx } = buildMockTx({ existingCount: 3, diff --git a/backend/src/modules/cases/lab-case-task.generator.ts b/backend/src/modules/cases/lab-case-task.generator.ts index 3279291..47b4fbc 100644 --- a/backend/src/modules/cases/lab-case-task.generator.ts +++ b/backend/src/modules/cases/lab-case-task.generator.ts @@ -18,7 +18,7 @@ export async function generateLabCaseTasks( const toothProsthesisRows = await tx.labCaseToothProsthesis.findMany({ where: { labCaseId }, include: { - detail: { select: { id: true, treatmentType: true } }, + detail: { select: { id: true, treatmentType: true, toothSelectionGroups: true } }, }, }); @@ -58,19 +58,29 @@ export async function generateLabCaseTasks( const stepLabels = await resolveStepLabels(tx, allStepCodes, locale); - // Group teeth that share the same (treatment detail + prosthesis type): one task set - // per group, with each step covering every tooth in that group. + // Group by selection group + prosthesis type so connected spans stay one task set, + // and separate singles stay separate even with the same prosthesis type. const groups = new Map< string, - { treatmentDetailId: string; treatmentType: string; prosthesisTypeCode: string; teeth: string[] } + { + treatmentDetailId: string; + treatmentType: string; + prosthesisTypeCode: string; + selectionGroupId: string; + teeth: string[]; + } >(); for (const row of toothProsthesisRows) { - const key = `${row.treatmentDetailId}::${row.prosthesisTypeCode}`; + const selectionGroupId = + row.selectionGroupId?.trim() || + fallbackGroupIdForTooth(row.detail.toothSelectionGroups, row.tooth, row.prosthesisTypeCode); + const key = `${row.treatmentDetailId}::${selectionGroupId}::${row.prosthesisTypeCode}`; const group = groups.get(key) ?? { treatmentDetailId: row.treatmentDetailId, treatmentType: row.detail.treatmentType, prosthesisTypeCode: row.prosthesisTypeCode, + selectionGroupId, teeth: [], }; group.teeth.push(row.tooth); @@ -94,6 +104,7 @@ export async function generateLabCaseTasks( teeth, treatmentType: group.treatmentType, prosthesisTypeCode: group.prosthesisTypeCode, + selectionGroupId: group.selectionGroupId, workflowStepCode: step.workflowStepCode, stepOrder: step.stepOrder, stepLabel: stepLabels.get(step.workflowStepCode) ?? step.workflowStepCode, @@ -110,6 +121,24 @@ export async function generateLabCaseTasks( return taskRows.length; } +function fallbackGroupIdForTooth( + toothSelectionGroups: unknown, + tooth: string, + prosthesisTypeCode: string, +): string { + if (Array.isArray(toothSelectionGroups)) { + for (const row of toothSelectionGroups) { + if (!row || typeof row !== 'object') continue; + const rec = row as { groupId?: unknown; teeth?: unknown }; + if (typeof rec.groupId !== 'string') continue; + if (Array.isArray(rec.teeth) && rec.teeth.includes(tooth)) { + return rec.groupId; + } + } + } + return `legacy-${prosthesisTypeCode}`; +} + function sortTeeth(teeth: string[]): string[] { return [...new Set(teeth)].sort((a, b) => { const na = Number(a); diff --git a/backend/src/modules/treatments/dto/treatment.dto.ts b/backend/src/modules/treatments/dto/treatment.dto.ts index f8a0e97..3bb6a24 100644 --- a/backend/src/modules/treatments/dto/treatment.dto.ts +++ b/backend/src/modules/treatments/dto/treatment.dto.ts @@ -2,6 +2,7 @@ import { ArrayMinSize, IsArray, IsDateString, + IsIn, IsOptional, IsString, IsUUID, @@ -10,6 +11,19 @@ import { } from 'class-validator'; import { Type } from 'class-transformer'; +export class ToothSelectionGroupDto { + @IsString() + @MaxLength(64) + groupId: string; + + @IsIn(['connected', 'single']) + kind: 'connected' | 'single'; + + @IsArray() + @IsString({ each: true }) + teeth: string[]; +} + export class SaveTreatmentDetailDto { @IsString() @MaxLength(64) @@ -27,6 +41,12 @@ export class SaveTreatmentDetailDto { @IsString({ each: true }) teeth: string[]; + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ToothSelectionGroupDto) + toothSelectionGroups?: ToothSelectionGroupDto[]; + @IsOptional() @IsString() @MaxLength(5000) @@ -57,6 +77,11 @@ export class LabCaseToothProsthesisDto { @IsString() @MaxLength(64) prosthesisTypeCode: string; + + @IsOptional() + @IsString() + @MaxLength(64) + selectionGroupId?: string; } export class SaveLabCaseDto { diff --git a/backend/src/modules/treatments/treatment.utils.ts b/backend/src/modules/treatments/treatment.utils.ts index f2bfdfa..9a0e4b3 100644 --- a/backend/src/modules/treatments/treatment.utils.ts +++ b/backend/src/modules/treatments/treatment.utils.ts @@ -20,6 +20,42 @@ export function normalizeTeeth(teeth: unknown): string[] { return [...unique].sort(); } +export type ToothSelectionGroupNormalized = { + groupId: string; + kind: 'connected' | 'single'; + teeth: string[]; +}; + +export function normalizeToothSelectionGroups( + value: unknown, + fallbackTeeth: string[] = [], +): ToothSelectionGroupNormalized[] { + if (Array.isArray(value) && value.length > 0) { + const out: ToothSelectionGroupNormalized[] = []; + for (const row of value) { + if (!row || typeof row !== 'object') continue; + const rec = row as Record; + const groupId = typeof rec.groupId === 'string' && rec.groupId.trim() ? rec.groupId.trim() : ''; + if (!groupId) continue; + const kind = rec.kind === 'connected' ? 'connected' : 'single'; + const teeth = normalizeTeeth(rec.teeth); + if (teeth.length === 0) continue; + out.push({ + groupId, + kind: kind === 'connected' && teeth.length < 2 ? 'single' : kind, + teeth, + }); + } + if (out.length > 0) return out; + } + + return fallbackTeeth.map((tooth, index) => ({ + groupId: `legacy-${tooth}-${index}`, + kind: 'single' as const, + teeth: [tooth], + })); +} + export function generateTreatmentTitle( cases: { treatmentType: string; teeth: string[] }[], ): string { diff --git a/backend/src/modules/treatments/treatments.service.ts b/backend/src/modules/treatments/treatments.service.ts index 2bf2de8..6ea3cc4 100644 --- a/backend/src/modules/treatments/treatments.service.ts +++ b/backend/src/modules/treatments/treatments.service.ts @@ -30,6 +30,7 @@ import { LabCaseActivityService } from '../notifications/lab-case-activity.servi import { generateTreatmentTitle, normalizeTeeth, + normalizeToothSelectionGroups, } from './treatment.utils'; import { assertCompleteToothProsthesisMap } from './lab-case-send.validation'; import { hasEffectivePermission } from '../../common/membership-permissions'; @@ -51,7 +52,7 @@ const sentLabCaseInclude = { details: { include: { detail: { - select: { clientKey: true, treatmentType: true, teeth: true }, + select: { clientKey: true, treatmentType: true, teeth: true, toothSelectionGroups: true }, }, }, }, @@ -59,9 +60,9 @@ const sentLabCaseInclude = { orderBy: [{ sentAt: 'asc' as const }], include: { organization: { select: { id: true, name: true } } }, }, - tasks: { select: { status: true } }, + tasks: { select: { status: true, selectionGroupId: true, prosthesisTypeCode: true, teeth: true } }, toothProsthesis: { - select: { tooth: true, prosthesisTypeCode: true }, + select: { tooth: true, prosthesisTypeCode: true, selectionGroupId: true }, }, } satisfies Prisma.LabCaseInclude; @@ -93,7 +94,13 @@ const treatmentInclude = { details: { include: { detail: { - select: { id: true, clientKey: true, treatmentType: true, teeth: true }, + select: { + id: true, + clientKey: true, + treatmentType: true, + teeth: true, + toothSelectionGroups: true, + }, }, }, }, @@ -283,17 +290,36 @@ export class TreatmentsService { const taskProgress = this.mapTaskProgress(lc.tasks); const labOrg = lc.sends[lc.sends.length - 1]?.organization ?? null; const detailTeeth = detail ? normalizeTeeth(detail.teeth) : []; + const selectionGroups = normalizeToothSelectionGroups( + detail?.toothSelectionGroups, + detailTeeth, + ); + const connectedGroupIds = new Set( + selectionGroups.filter((g) => g.kind === 'connected').map((g) => g.groupId), + ); - const prosthesisByCode = new Map(); + const prosthesisByGroup = new Map< + string, + { prosthesisTypeCode: string; teeth: string[]; selectionGroupId: string; connected: boolean } + >(); for (const row of lc.toothProsthesis ?? []) { - const list = prosthesisByCode.get(row.prosthesisTypeCode) ?? []; - list.push(row.tooth); - prosthesisByCode.set(row.prosthesisTypeCode, list); + const selectionGroupId = row.selectionGroupId || `legacy-${row.prosthesisTypeCode}`; + const key = `${selectionGroupId}::${row.prosthesisTypeCode}`; + const entry = prosthesisByGroup.get(key) ?? { + prosthesisTypeCode: row.prosthesisTypeCode, + teeth: [], + selectionGroupId, + connected: connectedGroupIds.has(row.selectionGroupId), + }; + entry.teeth.push(row.tooth); + prosthesisByGroup.set(key, entry); } - const prosthesisGroups = [...prosthesisByCode.entries()] - .map(([prosthesisTypeCode, teeth]) => ({ - prosthesisTypeCode, - teeth: [...new Set(teeth)].sort(), + const prosthesisGroups = [...prosthesisByGroup.values()] + .map((group) => ({ + prosthesisTypeCode: group.prosthesisTypeCode, + teeth: [...new Set(group.teeth)].sort(), + connected: group.connected, + selectionGroupId: group.selectionGroupId, })) .sort((a, b) => a.prosthesisTypeCode.localeCompare(b.prosthesisTypeCode)); @@ -377,13 +403,21 @@ export class TreatmentsService { this.treatmentCatalog.assertKnownTreatmentType(d.treatmentType); } - const normalizedDetails = dto.details.map((d, index) => ({ - ...d, - sortOrder: index, - teeth: normalizeTeeth(d.teeth), - comment: d.comment?.trim() || null, - attachmentIds: d.attachmentIds ?? [], - })); + const normalizedDetails = dto.details.map((d, index) => { + const teeth = normalizeTeeth(d.teeth); + const toothSelectionGroups = normalizeToothSelectionGroups( + d.toothSelectionGroups, + teeth, + ); + return { + ...d, + sortOrder: index, + teeth, + toothSelectionGroups, + comment: d.comment?.trim() || null, + attachmentIds: d.attachmentIds ?? [], + }; + }); const title = generateTreatmentTitle( normalizedDetails.map((d) => ({ treatmentType: d.treatmentType, teeth: d.teeth })), @@ -454,6 +488,7 @@ export class TreatmentsService { sortOrder: d.sortOrder, treatmentType: d.treatmentType, teeth: d.teeth, + toothSelectionGroups: d.toothSelectionGroups, comment: d.comment, }, }) @@ -464,6 +499,7 @@ export class TreatmentsService { sortOrder: d.sortOrder, treatmentType: d.treatmentType, teeth: d.teeth, + toothSelectionGroups: d.toothSelectionGroups, comment: d.comment, }, }); @@ -642,6 +678,7 @@ export class TreatmentsService { treatmentDetailId: tp.treatmentDetailId, tooth: tp.tooth, prosthesisTypeCode: tp.prosthesisTypeCode, + selectionGroupId: tp.selectionGroupId?.trim() || '', })), }); } @@ -1091,6 +1128,7 @@ export class TreatmentsService { clientKey?: string | null; treatmentType: string; teeth: unknown; + toothSelectionGroups?: unknown; comment?: string | null; attachments?: Array<{ id: string; @@ -1114,11 +1152,13 @@ export class TreatmentsService { }) { const labCase = d.labCaseLink?.labCase; const taskProgress = this.mapTaskProgress(labCase?.tasks ?? []); + const teeth = normalizeTeeth(d.teeth); return { id: d.id, clientId: d.clientKey ?? d.id, treatmentType: d.treatmentType, - teeth: normalizeTeeth(d.teeth), + teeth, + toothSelectionGroups: normalizeToothSelectionGroups(d.toothSelectionGroups, teeth), notes: d.comment ?? null, attachmentMetas: (d.attachments ?? []).map((a) => this.mapAttachment(a)), labCaseId: labCase?.id ?? null, @@ -1143,7 +1183,13 @@ export class TreatmentsService { dueDate?: Date | null; details?: Array<{ treatmentDetailId: string; - detail?: { id: string; clientKey: string | null; treatmentType: string; teeth: unknown }; + detail?: { + id: string; + clientKey: string | null; + treatmentType: string; + teeth: unknown; + toothSelectionGroups?: unknown; + }; }>; sends?: Array<{ organizationId: string; @@ -1154,6 +1200,7 @@ export class TreatmentsService { treatmentDetailId: string; tooth: string; prosthesisTypeCode: string; + selectionGroupId?: string; }>; attachments?: Array<{ attachment: { @@ -1167,6 +1214,9 @@ export class TreatmentsService { tasks?: Array<{ id: string; status: LabTaskStatus }>; }) { const taskProgress = this.mapTaskProgress(lc.tasks ?? []); + const detailTeeth = lc.details?.[0]?.detail + ? normalizeTeeth(lc.details[0].detail.teeth) + : []; return { id: lc.id, clientId: lc.clientKey ?? lc.id, @@ -1180,13 +1230,18 @@ export class TreatmentsService { id: lc.details[0].detail?.id ?? lc.details[0].treatmentDetailId, clientId: lc.details[0].detail?.clientKey ?? lc.details[0].treatmentDetailId, treatmentType: lc.details[0].detail?.treatmentType ?? '', - teeth: lc.details[0].detail ? normalizeTeeth(lc.details[0].detail.teeth) : [], + teeth: detailTeeth, + toothSelectionGroups: normalizeToothSelectionGroups( + lc.details[0].detail?.toothSelectionGroups, + detailTeeth, + ), } : null, toothProsthesis: (lc.toothProsthesis ?? []).map((tp) => ({ treatmentDetailId: tp.treatmentDetailId, tooth: tp.tooth, prosthesisTypeCode: tp.prosthesisTypeCode, + selectionGroupId: tp.selectionGroupId ?? '', })), attachments: (lc.attachments ?? []).map((row) => ({ id: row.attachment.id, diff --git a/frontend/messages/en.json b/frontend/messages/en.json index a9fcf8c..b57d8fc 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -661,6 +661,7 @@ "detailsSubtitle": "Plan teeth, type, notes, and attachments for each detail line.", "addDetail": "Add detail", "confirmRemoveDetail": "Remove this treatment detail?", + "removeDetailAria": "Remove detail {n}", "detailLabel": "Detail {n}", "detailSentBadge": "sent", "detailLockedInShipment": "This detail was sent to a lab and can no longer be edited.", @@ -799,8 +800,19 @@ "noActiveOrgs": "No active linked organizations.", "confirmSend": "Confirm send", "toothChartTitle": "FDI tooth chart", + "entryWizardLabel": "Treatment entry steps", + "entryStepTeeth": "Teeth", + "entryStepContent": "Content", + "entryStepLab": "Lab", + "entryStepBack": "Back", + "entryStepNext": "Next", + "entryStepLabUnavailable": "Lab dispatch is available after you save a lab-dependent detail with teeth selected.", "toothChartTitleCompact": "Tooth chart", - "toothChartHint": "Tap teeth to multi-select. Applies to the active detail.", + "toothChartHint": "Tap teeth to select. Shift-click another tooth in the same arch to select a connected span.", + "toothConnectedHint": "Connected selection", + "toothConnectedSuffix": " (connected)", + "connectedBadge": "Connected", + "prosthesisConnectedLabel": "Connected teeth prosthesis type", "toothChartWholePlan": "Full View", "labShipmentAttachments": "Files for the lab", "labShipmentAttachmentsHint": "Select which attachments from this detail are included in this shipment. None are sent by default.", diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index 3b48635..ec8ad15 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -662,6 +662,7 @@ "detailsSubtitle": "دندان‌ها، نوع، یادداشت و پیوست‌ها را برای هر خط جزئیات برنامه‌ریزی کنید.", "addDetail": "افزودن جزئیات", "confirmRemoveDetail": "این جزئیات درمان حذف شود؟", + "removeDetailAria": "حذف جزئیات {n}", "detailLabel": "جزئیات {n}", "detailSentBadge": "ارسال‌شده", "detailLockedInShipment": "این جزئیات به لابراتوار ارسال شده و دیگر قابل ویرایش نیست.", @@ -801,7 +802,18 @@ "confirmSend": "تأیید ارسال", "toothChartTitle": "نمودار دندان‌ها FDI", "toothChartTitleCompact": "نمودار دندان", - "toothChartHint": "برای انتخاب چندگانه روی دندان‌ها ضربه بزنید. برای جزئیات فعال اعمال می‌شود.", + "toothChartHint": "برای انتخاب روی دندان ضربه بزنید. Shift+کلیک روی دندان دیگر در همان قوس، یک بازه متصل می‌سازد.", + "toothConnectedHint": "انتخاب متصل", + "toothConnectedSuffix": " (متصل)", + "connectedBadge": "متصل", + "prosthesisConnectedLabel": "نوع پروتز دندان‌های متصل", + "entryWizardLabel": "مراحل ثبت درمان", + "entryStepTeeth": "دندان‌ها", + "entryStepContent": "محتوا", + "entryStepLab": "لابراتوار", + "entryStepBack": "قبلی", + "entryStepNext": "بعدی", + "entryStepLabUnavailable": "ارسال به لابراتوار پس از ذخیره جزئیات وابسته به لاب با انتخاب دندان در دسترس است.", "toothChartWholePlan": "نمایش کامل", "labShipmentAttachments": "فایل‌ها برای لابراتوار", "labShipmentAttachmentsHint": "انتخاب کنید کدام پیوست‌های این جزئیات در این محموله ارسال شوند. پیش‌فرض هیچ‌کدام نیست.", diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index 426e9b4..b7f32f1 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -661,6 +661,7 @@ "detailsSubtitle": "Plan tanden, type, notities en bijlagen per detailregel.", "addDetail": "Detail toevoegen", "confirmRemoveDetail": "Dit behandelingsdetail verwijderen?", + "removeDetailAria": "Detail {n} verwijderen", "detailLabel": "Detail {n}", "detailSentBadge": "verzonden", "detailLockedInShipment": "Dit detail is naar het lab verzonden en kan niet meer worden bewerkt.", @@ -800,7 +801,18 @@ "confirmSend": "Bevestig verzending", "toothChartTitle": "FDI-tanddiagram", "toothChartTitleCompact": "Tanddiagram", - "toothChartHint": "Tik op tanden om meerdere te selecteren. Geldt voor het actieve detail.", + "toothChartHint": "Tik op tanden om te selecteren. Shift-klik op een andere tand in dezelfde boog voor een verbonden span.", + "toothConnectedHint": "Verbonden selectie", + "toothConnectedSuffix": " (verbonden)", + "connectedBadge": "Verbonden", + "prosthesisConnectedLabel": "Prothesetype voor verbonden tanden", + "entryWizardLabel": "Behandeling invoerstappen", + "entryStepTeeth": "Tanden", + "entryStepContent": "Inhoud", + "entryStepLab": "Lab", + "entryStepBack": "Terug", + "entryStepNext": "Volgende", + "entryStepLabUnavailable": "Labverzending is beschikbaar nadat u een lab-afhankelijk detail met geselecteerde tanden hebt opgeslagen.", "toothChartWholePlan": "Volledig overzicht", "labShipmentAttachments": "Bestanden voor het lab", "labShipmentAttachmentsHint": "Kies welke bijlagen van dit detail bij deze zending horen. Standaard worden er geen meegestuurd.", diff --git a/frontend/src/components/treatment/toothSelectionGroups.ts b/frontend/src/components/treatment/toothSelectionGroups.ts new file mode 100644 index 0000000..727ce98 --- /dev/null +++ b/frontend/src/components/treatment/toothSelectionGroups.ts @@ -0,0 +1,189 @@ +import type { FdiToothId } from '@/types/treatment'; +import { FDI_LOWER_LEFT_TO_RIGHT, FDI_UPPER_LEFT_TO_RIGHT } from '@/components/treatment/fdiToothMeta'; + +export type ToothSelectionKind = 'connected' | 'single'; + +export type ToothSelectionGroup = { + groupId: string; + kind: ToothSelectionKind; + teeth: FdiToothId[]; +}; + +function newGroupId(): string { + return typeof crypto !== 'undefined' && 'randomUUID' in crypto + ? crypto.randomUUID() + : `tg-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; +} + +export function deriveTeethFromGroups(groups: ToothSelectionGroup[]): FdiToothId[] { + const set = new Set(); + for (const group of groups) { + for (const tooth of group.teeth) set.add(tooth); + } + return [...set].sort() as FdiToothId[]; +} + +/** Legacy / empty groups: each selected tooth becomes its own single group. */ +export function groupsFromFlatTeeth( + teeth: readonly FdiToothId[], + existing?: ToothSelectionGroup[] | null, +): ToothSelectionGroup[] { + if (existing && existing.length > 0) { + const flat = new Set(teeth); + const next = existing + .map((g) => ({ + ...g, + teeth: g.teeth.filter((t) => flat.has(t)) as FdiToothId[], + })) + .filter((g) => g.teeth.length > 0) + .map((g) => + g.kind === 'connected' && g.teeth.length < 2 + ? { ...g, kind: 'single' as const } + : g, + ); + const covered = new Set(next.flatMap((g) => g.teeth)); + for (const tooth of teeth) { + if (!covered.has(tooth)) { + next.push({ groupId: newGroupId(), kind: 'single', teeth: [tooth] }); + } + } + return next; + } + return teeth.map((tooth) => ({ + groupId: newGroupId(), + kind: 'single' as const, + teeth: [tooth], + })); +} + +export function connectedTeethSet(groups: ToothSelectionGroup[]): Set { + const set = new Set(); + for (const group of groups) { + if (group.kind !== 'connected') continue; + for (const tooth of group.teeth) set.add(tooth); + } + return set; +} + +function archOrder(tooth: FdiToothId): FdiToothId[] | null { + if ((FDI_UPPER_LEFT_TO_RIGHT as readonly string[]).includes(tooth)) { + return FDI_UPPER_LEFT_TO_RIGHT; + } + if ((FDI_LOWER_LEFT_TO_RIGHT as readonly string[]).includes(tooth)) { + return FDI_LOWER_LEFT_TO_RIGHT; + } + return null; +} + +export function sameArch(a: FdiToothId, b: FdiToothId): boolean { + const archA = archOrder(a); + const archB = archOrder(b); + return Boolean(archA && archB && archA === archB); +} + +export function teethBetweenInclusive(a: FdiToothId, b: FdiToothId): FdiToothId[] | null { + const arch = archOrder(a); + if (!arch || !sameArch(a, b)) return null; + const i = arch.indexOf(a); + const j = arch.indexOf(b); + if (i < 0 || j < 0) return null; + const [from, to] = i <= j ? [i, j] : [j, i]; + return arch.slice(from, to + 1); +} + +/** + * Plain click: + * - unselected → add as single + * - selected single → remove + * - selected in connected → drop the whole connected span and keep only this tooth as a single + */ +export function toggleToothInGroups( + groups: ToothSelectionGroup[], + tooth: FdiToothId, +): ToothSelectionGroup[] { + const owning = groups.find((g) => g.teeth.includes(tooth)); + if (!owning) { + return [...groups, { groupId: newGroupId(), kind: 'single', teeth: [tooth] }]; + } + + if (owning.kind === 'single') { + return groups.filter((g) => g.groupId !== owning.groupId); + } + + // Collapse connected span: only the clicked tooth remains, as a separate single. + const next = groups.filter((g) => g.groupId !== owning.groupId); + next.push({ groupId: newGroupId(), kind: 'single', teeth: [tooth] }); + return next; +} + +/** + * Shift-click creates a connected span between anchor and target on the same arch. + * Any existing groups that intersect the span are replaced by the new connected group. + */ +export function applyShiftRange( + groups: ToothSelectionGroup[], + anchor: FdiToothId, + target: FdiToothId, +): ToothSelectionGroup[] | null { + if (anchor === target) return null; + const span = teethBetweenInclusive(anchor, target); + if (!span || span.length < 2) return null; + + const spanSet = new Set(span); + const next = groups + .map((g) => ({ + ...g, + teeth: g.teeth.filter((t) => !spanSet.has(t)) as FdiToothId[], + })) + .filter((g) => g.teeth.length > 0) + .map((g) => + g.kind === 'connected' && g.teeth.length < 2 + ? { ...g, kind: 'single' as const } + : g, + ); + + next.push({ groupId: newGroupId(), kind: 'connected', teeth: span }); + return next; +} + +/** + * Drop prosthesis rows for teeth no longer selected on a detail, and refresh + * selectionGroupId so connected→single collapses do not leave stale group ids. + */ +export function pruneToothProsthesisForGroups< + T extends { detailClientId: string; tooth: string; selectionGroupId?: string }, +>(toothProsthesis: T[], detailClientId: string, groups: ToothSelectionGroup[]): T[] { + const toothToGroup = new Map(); + for (const group of groups) { + for (const tooth of group.teeth) toothToGroup.set(tooth, group); + } + return toothProsthesis + .filter((tp) => tp.detailClientId !== detailClientId || toothToGroup.has(tp.tooth)) + .map((tp) => { + if (tp.detailClientId !== detailClientId) return tp; + const group = toothToGroup.get(tp.tooth); + if (!group) return tp; + return { ...tp, selectionGroupId: group.groupId }; + }); +} + +export function normalizeToothSelectionGroups(value: unknown): ToothSelectionGroup[] { + if (!Array.isArray(value)) return []; + const out: ToothSelectionGroup[] = []; + for (const row of value) { + if (!row || typeof row !== 'object') continue; + const rec = row as Record; + const groupId = typeof rec.groupId === 'string' ? rec.groupId : newGroupId(); + const kind = rec.kind === 'connected' ? 'connected' : 'single'; + const teeth = Array.isArray(rec.teeth) + ? (rec.teeth.filter((t) => typeof t === 'string') as FdiToothId[]) + : []; + if (teeth.length === 0) continue; + out.push({ + groupId, + kind: kind === 'connected' && teeth.length < 2 ? 'single' : kind, + teeth, + }); + } + return out; +} diff --git a/frontend/src/components/ui/lab/LabCaseProsthesisGroupsList.tsx b/frontend/src/components/ui/lab/LabCaseProsthesisGroupsList.tsx index 17abc5f..44958ba 100644 --- a/frontend/src/components/ui/lab/LabCaseProsthesisGroupsList.tsx +++ b/frontend/src/components/ui/lab/LabCaseProsthesisGroupsList.tsx @@ -1,5 +1,6 @@ 'use client'; +import { ConnectedSelectionBadge } from '@/components/ui/treatment/ConnectedSelectionBadge'; import { formatToothList, prosthesisTypeColorFromCatalog, @@ -9,6 +10,8 @@ import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog'; export type LabCaseProsthesisGroup = { prosthesisTypeCode: string; teeth: string[]; + connected?: boolean; + selectionGroupId?: string; }; interface LabCaseProsthesisGroupsListProps { @@ -31,12 +34,15 @@ export function LabCaseProsthesisGroupsList({
    {groups.map((group) => (
  • + {group.connected ? ( + + ) : null} {prosthesisLabel(group.prosthesisTypeCode, prosthesisCatalog)} diff --git a/frontend/src/components/ui/shared/WizardStepper.tsx b/frontend/src/components/ui/shared/WizardStepper.tsx new file mode 100644 index 0000000..a874145 --- /dev/null +++ b/frontend/src/components/ui/shared/WizardStepper.tsx @@ -0,0 +1,100 @@ +'use client'; + +export type WizardStepItem = { + id: string; + label: string; +}; + +type WizardStepperProps = { + steps: WizardStepItem[]; + currentStepId: string; + onStepChange: (stepId: string) => void; + /** Accessible name for the stepper. */ + 'aria-label': string; + className?: string; +}; + +/** + * Compact horizontal progress stepper — numbered nodes + connector rail. + * Distinct from chip/tab row patterns used elsewhere (e.g. treatment detail chips). + */ +export function WizardStepper({ + steps, + currentStepId, + onStepChange, + 'aria-label': ariaLabel, + className, +}: WizardStepperProps) { + const currentIndex = Math.max( + 0, + steps.findIndex((step) => step.id === currentStepId), + ); + + return ( + + ); +} diff --git a/frontend/src/components/ui/treatment/ConnectedSelectionBadge.tsx b/frontend/src/components/ui/treatment/ConnectedSelectionBadge.tsx new file mode 100644 index 0000000..b521508 --- /dev/null +++ b/frontend/src/components/ui/treatment/ConnectedSelectionBadge.tsx @@ -0,0 +1,29 @@ +'use client'; + +import { useTranslations } from 'next-intl'; +import { Badge } from '@/components/ui/shared/Badge'; + +/** Primary-tint styling matching the previous custom connected pill. */ +const CONNECTED_BADGE_STYLE = { + backgroundColor: 'color-mix(in srgb, var(--color-primary) 10%, transparent)', + color: 'var(--color-primary)', + borderColor: 'color-mix(in srgb, var(--color-primary) 40%, transparent)', +} as const; + +type ConnectedSelectionBadgeProps = { + className?: string; +}; + +/** Compact “Connected” label — shared Badge with primary tint (not default variant colors). */ +export function ConnectedSelectionBadge({ className }: ConnectedSelectionBadgeProps) { + const t = useTranslations('treatment'); + return ( + + {t('connectedBadge')} + + ); +} diff --git a/frontend/src/components/ui/treatment/FdiToothChart.tsx b/frontend/src/components/ui/treatment/FdiToothChart.tsx index 05436a0..7660ba3 100644 --- a/frontend/src/components/ui/treatment/FdiToothChart.tsx +++ b/frontend/src/components/ui/treatment/FdiToothChart.tsx @@ -21,7 +21,9 @@ function quadrantMirrored(fdi: FdiToothId): boolean { interface FdiToothChartProps { selected: ReadonlySet; - onToggle?: (fdi: FdiToothId) => void; + /** Teeth that belong to a connected selection span (dots above/below). */ + connectedTeeth?: ReadonlySet; + onToggle?: (fdi: FdiToothId, event: { shiftKey: boolean }) => void; disabled?: boolean; /** Non-interactive display (Cases / connection history). */ readOnly?: boolean; @@ -38,6 +40,7 @@ interface FdiToothChartProps { export function FdiToothChart({ selected, + connectedTeeth, onToggle, disabled, readOnly = false, @@ -147,6 +150,7 @@ export function FdiToothChart({ const kind = getToothShapeKind(fdi); const gid = `${uid}-g-${fdi}-${i}`; const isSel = selected.has(fdi); + const isConnected = Boolean(connectedTeeth?.has(fdi)); const size = toothSizeClass(fdi); const tweak = TOOTH_TWEAKS[fdi]; const offsetY = tweak ? tweak.offset : archOffset(i, teeth.length, upper); @@ -168,24 +172,44 @@ export function FdiToothChart({ /> ); + const connectedDot = isConnected ? ( + + ) : ( + + ); + return (
    + {upper ? connectedDot : null}
    {interactive ? (
    )}
    + {!upper ? connectedDot : null} ); })} @@ -215,8 +240,8 @@ export function FdiToothChart({ <>

    {t('upperArch')}

    -
    -
    +
    +
    { return ids; } -function prosthesisTeethRows( +type ProsthesisGroupRow = { + groupId: string; + kind: 'connected' | 'single'; + teeth: string[]; + detailClientId: string; + detailNumber: number; +}; + +function prosthesisGroupRows( labCase: LabCaseDraft, activeDetail: TreatmentDetailDraft, detailNumber: number, -): Array<{ detailClientId: string; tooth: string; detailNumber: number }> { +): ProsthesisGroupRow[] { if (labCase.detailClientId !== activeDetail.clientId) return []; if (activeDetail.treatmentType !== 'prosthesis') return []; - return activeDetail.teeth.map((tooth) => ({ + const groups = + activeDetail.toothSelectionGroups.length > 0 + ? activeDetail.toothSelectionGroups + : groupsFromFlatTeeth(activeDetail.teeth); + + return groups.map((g) => ({ + groupId: g.groupId, + kind: g.kind, + teeth: g.teeth, detailClientId: activeDetail.clientId, - tooth, detailNumber, })); } function isProsthesisMapComplete( labCase: LabCaseDraft, - rows: Array<{ detailClientId: string; tooth: string }>, + rows: ProsthesisGroupRow[], ): boolean { 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), + row.teeth.every((tooth) => + labCase.toothProsthesis.some( + (tp) => + tp.detailClientId === row.detailClientId && + tp.tooth === tooth && + tp.selectionGroupId === row.groupId && + Boolean(tp.prosthesisTypeCode), + ), ), ); } @@ -148,11 +168,12 @@ export function LabCasesDispatchPanel({ : null; const prosthesisRows = activeLabCase && activeDetail - ? prosthesisTeethRows(activeLabCase, activeDetail, activeDetailNumber) + ? prosthesisGroupRows(activeLabCase, activeDetail, activeDetailNumber) : []; const prosthesisComplete = activeLabCase ? isProsthesisMapComplete(activeLabCase, prosthesisRows) : true; + const flatToothCount = prosthesisRows.reduce((sum, row) => sum + row.teeth.length, 0); useEffect(() => { if (!activeLabCase?.destinationOrganizationId) { @@ -196,28 +217,36 @@ export function LabCasesDispatchPanel({ ); } - function setToothProsthesis( - detailClientId: string, - tooth: string, - prosthesisTypeCode: string, - ) { + function setGroupProsthesis(row: ProsthesisGroupRow, prosthesisTypeCode: string) { if (!activeLabCase) return; + const toothSet = new Set(row.teeth); const rest = activeLabCase.toothProsthesis.filter( - (tp) => !(tp.detailClientId === detailClientId && tp.tooth === tooth), + (tp) => !(tp.detailClientId === row.detailClientId && toothSet.has(tp.tooth)), ); const next = prosthesisTypeCode - ? [...rest, { detailClientId, tooth, prosthesisTypeCode }] + ? [ + ...rest, + ...row.teeth.map((tooth) => ({ + detailClientId: row.detailClientId, + tooth, + prosthesisTypeCode, + selectionGroupId: row.groupId, + })), + ] : 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, - })); + const next = prosthesisRows.flatMap((row) => + row.teeth.map((tooth) => ({ + detailClientId: row.detailClientId, + tooth, + prosthesisTypeCode: code, + selectionGroupId: row.groupId, + })), + ); updateActiveLabCase({ toothProsthesis: next }); } @@ -316,49 +345,55 @@ export function LabCasesDispatchPanel({ ); } - const byType = new Map(); - for (const tp of activeLabCase.toothProsthesis) { - if (tp.detailClientId !== activeDetail.clientId) continue; - if (!tp.prosthesisTypeCode) continue; - const list = byType.get(tp.prosthesisTypeCode) ?? []; - list.push(tp.tooth); - byType.set(tp.prosthesisTypeCode, list); - } + const selectionGroups = + activeDetail.toothSelectionGroups.length > 0 + ? activeDetail.toothSelectionGroups + : groupsFromFlatTeeth(activeDetail.teeth); - const uniqueSelected = [...new Set(activeDetail.teeth)]; - const mappedTeeth = new Set(); - for (const teeth of byType.values()) { - for (const tooth of teeth) mappedTeeth.add(tooth); - } - const unmapped = uniqueSelected.filter((t) => !mappedTeeth.has(t)); - - const groups = [...byType.entries()] - .map(([code, teeth]) => ({ + const rows = selectionGroups.map((group) => { + const codes = new Set( + activeLabCase.toothProsthesis + .filter( + (tp) => + tp.detailClientId === activeDetail.clientId && + group.teeth.includes(tp.tooth as never) && + tp.prosthesisTypeCode, + ) + .map((tp) => tp.prosthesisTypeCode), + ); + const code = codes.size === 1 ? [...codes][0] : ''; + return { + groupId: group.groupId, + kind: group.kind, + teeth: group.teeth, code, - teeth: [...new Set(teeth)].sort((a, b) => a.localeCompare(b)), - label: prosthesisOptions.find((p) => p.code === code)?.label ?? code, - })) - .sort((a, b) => a.code.localeCompare(b.code)); + label: code + ? prosthesisOptions.find((p) => p.code === code)?.label ?? code + : t('prosthesisUnassigned'), + }; + }); return (

    {t('detailLabel', { n: activeDetailNumber })} · {typeLabel}

    - {groups.map((g) => ( + {rows.map((g) => (

    + {g.kind === 'connected' ? ( + + ) : null} {g.label}: {g.teeth.join(', ')}

    ))} - {unmapped.length > 0 ? ( -

    - {t('prosthesisUnassigned')}: {unmapped.join(', ')} -

    - ) : null}
    ); } @@ -514,75 +549,82 @@ export function LabCasesDispatchPanel({

    {t('prosthesisTypesTitle')}

    - -
    - - - - - - - - - - {prosthesisRows.map((row) => { - const current = - activeLabCase.toothProsthesis.find( - (tp) => - tp.detailClientId === row.detailClientId && - tp.tooth === row.tooth, - )?.prosthesisTypeCode ?? ''; - return ( - - - - - - ); - })} - -
    {t('prosthesisColTooth')}{t('prosthesisColDetail')}{t('prosthesisColType')}
    {row.tooth} - {t('detailLabel', { n: row.detailNumber })} - - -
    + {flatToothCount > 1 && prosthesisRows.every((r) => r.kind === 'single') ? ( + + ) : null} +
    + {prosthesisRows.map((row) => { + const current = + activeLabCase.toothProsthesis.find( + (tp) => + tp.detailClientId === row.detailClientId && + tp.selectionGroupId === row.groupId && + row.teeth.includes(tp.tooth), + )?.prosthesisTypeCode ?? + activeLabCase.toothProsthesis.find( + (tp) => + tp.detailClientId === row.detailClientId && + row.teeth.includes(tp.tooth), + )?.prosthesisTypeCode ?? + ''; + return ( + + ); + })}
    ) : null} diff --git a/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx b/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx index 81735bd..4285383 100644 --- a/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx +++ b/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx @@ -2,12 +2,14 @@ import { useRef } from 'react'; import { useTranslations } from 'next-intl'; +import { Trash2 } from 'lucide-react'; import { Button } from '@/components/ui/shared/Button'; import { Dropdown } from '@/components/ui/shared/Dropdown'; import { autosaveStatusClass, labPendingBannerClass, labSentBannerClass, + labBlockedBannerClass, } from '@/components/treatment/treatmentStatusStyles'; import type { TreatmentDetailDraft } from '@/types/treatment'; import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; @@ -17,7 +19,6 @@ import { isDetailTypeSelected, isLabDependentDetailMissingTeeth, } from '@/components/treatment/treatmentDetailRules'; -import { labBlockedBannerClass } from '@/components/treatment/treatmentStatusStyles'; interface TreatmentDetailsEditorProps { details: TreatmentDetailDraft[]; @@ -32,8 +33,12 @@ interface TreatmentDetailsEditorProps { saveStatus: 'idle' | 'dirty' | 'saving' | 'saved' | 'error'; uploadBusy: boolean; onAddDetail: () => void; - onRemoveDetail: () => void; + onRemoveDetail?: (detailClientId: string) => void; onUploadFiles: (files: FileList | null) => void; + /** Detail chips + Add detail (default true). */ + showChrome?: boolean; + /** Type / notes / attachments fields (default true). */ + showFields?: boolean; } export function TreatmentDetailsEditor({ @@ -51,6 +56,8 @@ export function TreatmentDetailsEditor({ onAddDetail, onRemoveDetail, onUploadFiles, + showChrome = true, + showFields = true, }: TreatmentDetailsEditorProps) { const t = useTranslations('treatment'); const tCommon = useTranslations('common'); @@ -61,7 +68,6 @@ export function TreatmentDetailsEditor({ const locked = isDetailLocked(activeDetail); const readOnly = disabled || locked; - const canRemoveDetail = canEdit && !disabled && !locked && details.length > 1; const treatmentTypeTextColor = isDetailTypeSelected(activeDetail) ? treatmentTypeColor( activeDetail.treatmentType, @@ -75,159 +81,191 @@ export function TreatmentDetailsEditor({ labDependentCodes, ); + if (!showChrome && !showFields) return null; + return (
    -
    -
    -

    {t('detailsTitle')}

    -

    {t('detailsSubtitle')}

    -
    - -
    - -
    - {details.map((d, idx) => ( - - ))} -
    - -
    - {locked && ( -

    {t('detailLockedInShipment')}

    - )} - {showPendingLabHint && ( -

    {t('detailPendingLabSend')}

    - )} - {showMissingTeethLabBlock && ( -

    {t('labShipmentBlockedBody')}

    - )} - -