From ed7e7b1d8f45b84482a44427ade78a8bab1faff6 Mon Sep 17 00:00:00 2001 From: Admin Date: Tue, 7 Jul 2026 13:13:04 +0330 Subject: [PATCH] improvement: all clinic side ui components related to treatment types updated based on the new real world data. --- backend/prisma/catalog-seed-data.ts | 31 ++++++-- .../migration.sql | 3 + backend/prisma/reset-treatment-data.ts | 46 +++++++++--- backend/prisma/schema.prisma | 12 ++-- backend/prisma/seed.ts | 6 ++ .../treatment-catalog.controller.ts | 29 ++++++-- .../treatment-catalog.service.ts | 30 ++++++-- .../(dashboard)/appointments/page.tsx | 14 +++- .../appointments/AppointmentBookingModal.tsx | 42 +++++------ .../AppointmentOverlapPopover.tsx | 53 +++++++------- .../appointments/AppointmentScheduleGrid.tsx | 9 ++- .../AppointmentScheduleLegend.tsx | 28 +++++--- .../appointments/appointmentPurposeStyles.ts | 71 +++++++++---------- .../ui/treatment/AppointmentsStrip.tsx | 10 +-- .../ui/treatment/TreatmentTypeBadge.tsx | 9 ++- .../ui/treatment/TreatmentWorkspace.tsx | 6 +- .../ui/treatment/treatmentTypeDisplay.ts | 43 ++++++++++- frontend/src/lib/api/treatment-catalog.ts | 10 ++- frontend/src/types/appointment.ts | 15 ++-- frontend/src/types/treatment-catalog.ts | 2 + 20 files changed, 321 insertions(+), 148 deletions(-) create mode 100644 backend/prisma/migrations/20260707120000_treatment_type_context/migration.sql diff --git a/backend/prisma/catalog-seed-data.ts b/backend/prisma/catalog-seed-data.ts index bfb7a7e..bdf5508 100644 --- a/backend/prisma/catalog-seed-data.ts +++ b/backend/prisma/catalog-seed-data.ts @@ -7,7 +7,17 @@ export type CatalogTranslationSeed = { label: string; }; -export const TREATMENT_TYPES = [ +export type TreatmentTypeSeed = { + code: string; + labDependent: boolean; + sortOrder: number; + /** Selectable when booking an appointment. Defaults to true. */ + availableInAppointments?: boolean; + /** Selectable as a treatment plan detail. Defaults to true. */ + availableInTreatment?: boolean; +}; + +export const TREATMENT_TYPES: readonly TreatmentTypeSeed[] = [ { code: 'restoration', labDependent: false, sortOrder: 1 }, { code: 'specialized_restoration', labDependent: false, sortOrder: 2 }, { code: 'radiography', labDependent: false, sortOrder: 3 }, @@ -19,11 +29,23 @@ export const TREATMENT_TYPES = [ { code: 'perio', labDependent: false, sortOrder: 9 }, { code: 'pediatrics', labDependent: false, sortOrder: 10 }, { code: 'extraction', labDependent: false, sortOrder: 11 }, - { code: 'clinic_visit', labDependent: false, sortOrder: 12 }, + // Appointment-only: not real treatment plan details. + { + code: 'clinic_visit', + labDependent: false, + sortOrder: 12, + availableInTreatment: false, + }, + { + code: 'continue_treatment', + labDependent: false, + sortOrder: 13, + availableInTreatment: false, + }, ] as const; /** Legacy codes kept for historical rows; hidden from catalog. */ -export const LEGACY_TREATMENT_TYPES = [ +export const LEGACY_TREATMENT_TYPES: readonly TreatmentTypeSeed[] = [ { code: 'consultation', labDependent: false, sortOrder: 99 }, { code: 'filling', labDependent: false, sortOrder: 100 }, { code: 'visit', labDependent: false, sortOrder: 101 }, @@ -207,7 +229,8 @@ const TREATMENT_LABELS: Record> = { perio: { en: 'Perio', fa: 'پریو', nl: 'Paro' }, pediatrics: { en: 'Pediatrics', fa: 'اطفال', nl: 'Kinderen' }, extraction: { en: 'Extraction', fa: 'کشیدن', nl: 'Extractie' }, - clinic_visit: { en: 'Clinic Visit', fa: 'درمانگاه', nl: 'Kliniekbezoek' }, + clinic_visit: { en: 'Clinic Visit', fa: 'ویزیت درمانگاه', nl: 'Kliniekbezoek' }, + continue_treatment: { en: 'Continue Treatment', fa: 'ادامه درمان', nl: 'Behandeling Voortzetten' }, consultation: { en: 'Consultation', fa: 'مشاوره', nl: 'Consult' }, filling: { en: 'Filling', fa: 'پر کردن', nl: 'Vulling' }, visit: { en: 'Visit', fa: 'ویزیت', nl: 'Bezoek' }, diff --git a/backend/prisma/migrations/20260707120000_treatment_type_context/migration.sql b/backend/prisma/migrations/20260707120000_treatment_type_context/migration.sql new file mode 100644 index 0000000..5224edb --- /dev/null +++ b/backend/prisma/migrations/20260707120000_treatment_type_context/migration.sql @@ -0,0 +1,3 @@ +-- Treatment type context flags: control which selection contexts each type appears in. +ALTER TABLE "treatment_types" ADD COLUMN "availableInAppointments" BOOLEAN NOT NULL DEFAULT true; +ALTER TABLE "treatment_types" ADD COLUMN "availableInTreatment" BOOLEAN NOT NULL DEFAULT true; diff --git a/backend/prisma/reset-treatment-data.ts b/backend/prisma/reset-treatment-data.ts index 1b93fdc..6bc6387 100644 --- a/backend/prisma/reset-treatment-data.ts +++ b/backend/prisma/reset-treatment-data.ts @@ -1,6 +1,9 @@ /** * Dev-only: truncate treatment and lab case data (preserves catalog tables). * Usage: npx ts-node prisma/reset-treatment-data.ts + * + * Safe to run before or after `prisma migrate deploy`: tables that do not yet + * exist are skipped instead of throwing. */ import { PrismaClient } from '@prisma/client'; import { config } from 'dotenv'; @@ -16,17 +19,44 @@ if (process.env.NODE_ENV === 'production') { const prisma = new PrismaClient(); +// FK-safe order: children before parents. +const TABLES_IN_ORDER = [ + 'lab_case_tasks', + 'lab_case_sends', + 'lab_case_tooth_prosthesis', + 'lab_case_details', + 'lab_cases', + 'treatment_detail_attachments', + 'treatment_details', + 'treatments', +]; + +async function tableExists(table: string): Promise { + const rows = await prisma.$queryRawUnsafe>( + `SELECT to_regclass('public."${table}"')::text AS exists`, + ); + return rows[0]?.exists != null; +} + async function main() { console.log('Truncating treatment and lab case data...'); - await prisma.$executeRawUnsafe('TRUNCATE TABLE "lab_case_tasks" CASCADE'); - await prisma.$executeRawUnsafe('TRUNCATE TABLE "lab_case_sends" CASCADE'); - await prisma.$executeRawUnsafe('TRUNCATE TABLE "lab_case_tooth_prosthesis" CASCADE'); - await prisma.$executeRawUnsafe('TRUNCATE TABLE "lab_case_details" CASCADE'); - await prisma.$executeRawUnsafe('TRUNCATE TABLE "lab_cases" CASCADE'); - await prisma.$executeRawUnsafe('TRUNCATE TABLE "treatment_detail_attachments" CASCADE'); - await prisma.$executeRawUnsafe('TRUNCATE TABLE "treatment_details" CASCADE'); - await prisma.$executeRawUnsafe('TRUNCATE TABLE "treatments" CASCADE'); + const existing: string[] = []; + for (const table of TABLES_IN_ORDER) { + if (await tableExists(table)) { + existing.push(table); + } else { + console.log(` - skipping "${table}" (does not exist yet)`); + } + } + + if (existing.length === 0) { + console.log('No target tables exist yet. Run `prisma migrate deploy` first.'); + return; + } + + const targets = existing.map((t) => `"${t}"`).join(', '); + await prisma.$executeRawUnsafe(`TRUNCATE TABLE ${targets} CASCADE`); console.log('Done.'); } diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 828cc38..f076995 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -224,11 +224,13 @@ model LabCaseSend { } model TreatmentType { - id String @id @default(uuid()) - code String @unique - labDependent Boolean @default(false) - sortOrder Int @default(0) - isActive Boolean @default(true) + id String @id @default(uuid()) + code String @unique + labDependent Boolean @default(false) + sortOrder Int @default(0) + isActive Boolean @default(true) + availableInAppointments Boolean @default(true) + availableInTreatment Boolean @default(true) @@map("treatment_types") } diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts index bbe550c..0a9a0dd 100644 --- a/backend/prisma/seed.ts +++ b/backend/prisma/seed.ts @@ -138,12 +138,16 @@ async function main() { for (const type of [...TREATMENT_TYPES, ...LEGACY_TREATMENT_TYPES]) { const isActive = TREATMENT_TYPES.some((t) => t.code === type.code); + const availableInAppointments = type.availableInAppointments ?? true; + const availableInTreatment = type.availableInTreatment ?? true; await prisma.treatmentType.upsert({ where: { code: type.code }, update: { labDependent: type.labDependent, sortOrder: type.sortOrder, isActive, + availableInAppointments, + availableInTreatment, }, create: { id: randomUUID(), @@ -151,6 +155,8 @@ async function main() { labDependent: type.labDependent, sortOrder: type.sortOrder, isActive, + availableInAppointments, + availableInTreatment, }, }); } diff --git a/backend/src/modules/treatment-catalog/treatment-catalog.controller.ts b/backend/src/modules/treatment-catalog/treatment-catalog.controller.ts index 062a603..fef1323 100644 --- a/backend/src/modules/treatment-catalog/treatment-catalog.controller.ts +++ b/backend/src/modules/treatment-catalog/treatment-catalog.controller.ts @@ -1,7 +1,10 @@ -import { Controller, Get, Req, UseGuards } from '@nestjs/common'; -import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { Controller, Get, Query, Req, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; -import { TreatmentCatalogService } from './treatment-catalog.service'; +import { + TreatmentCatalogContext, + TreatmentCatalogService, +} from './treatment-catalog.service'; @ApiTags('treatment-catalog') @ApiBearerAuth('JWT-auth') @@ -12,8 +15,24 @@ export class TreatmentCatalogController { @Get() @ApiOperation({ summary: 'List active treatment types with localized labels' }) - async list(@Req() req: { user?: { language?: string | null } }) { - const data = await this.treatmentCatalogService.list(req.user?.language); + @ApiQuery({ + name: 'context', + required: false, + enum: ['appointment', 'treatment'], + description: 'Filter to types selectable in the given context', + }) + async list( + @Req() req: { user?: { language?: string | null } }, + @Query('context') context?: string, + ) { + const normalizedContext = + context === 'appointment' || context === 'treatment' + ? (context as TreatmentCatalogContext) + : undefined; + const data = await this.treatmentCatalogService.list( + req.user?.language, + normalizedContext, + ); return { success: true, data }; } } diff --git a/backend/src/modules/treatment-catalog/treatment-catalog.service.ts b/backend/src/modules/treatment-catalog/treatment-catalog.service.ts index 7baf921..418bfb4 100644 --- a/backend/src/modules/treatment-catalog/treatment-catalog.service.ts +++ b/backend/src/modules/treatment-catalog/treatment-catalog.service.ts @@ -7,12 +7,16 @@ import { normalizeCatalogLocale, } from '../catalog/catalog-label.service'; +export type TreatmentCatalogContext = 'appointment' | 'treatment'; + export type TreatmentTypeCatalogEntry = { id: string; code: string; labDependent: boolean; sortOrder: number; label: string; + availableInAppointments: boolean; + availableInTreatment: boolean; }; @Injectable() @@ -33,7 +37,14 @@ export class TreatmentCatalogService implements OnModuleInit { const rows = await this.prisma.treatmentType.findMany({ where: { isActive: true }, orderBy: [{ sortOrder: 'asc' }, { code: 'asc' }], - select: { id: true, code: true, labDependent: true, sortOrder: true }, + select: { + id: true, + code: true, + labDependent: true, + sortOrder: true, + availableInAppointments: true, + availableInTreatment: true, + }, }); this.byCode = new Map( @@ -45,17 +56,26 @@ export class TreatmentCatalogService implements OnModuleInit { labDependent: row.labDependent, sortOrder: row.sortOrder, label: row.code, + availableInAppointments: row.availableInAppointments, + availableInTreatment: row.availableInTreatment, }, ]), ); this.loaded = true; } - async list(localeInput?: string | null): Promise { + async list( + localeInput?: string | null, + context?: TreatmentCatalogContext | null, + ): Promise { await this.ensureLabels(localeInput); - return [...this.byCode.values()].sort( - (a, b) => a.sortOrder - b.sortOrder || a.code.localeCompare(b.code), - ); + return [...this.byCode.values()] + .filter((entry) => { + if (context === 'appointment') return entry.availableInAppointments; + if (context === 'treatment') return entry.availableInTreatment; + return true; + }) + .sort((a, b) => a.sortOrder - b.sortOrder || a.code.localeCompare(b.code)); } private async ensureLabels(localeInput?: string | null) { diff --git a/frontend/src/app/[locale]/(dashboard)/appointments/page.tsx b/frontend/src/app/[locale]/(dashboard)/appointments/page.tsx index 9d0a025..573c324 100644 --- a/frontend/src/app/[locale]/(dashboard)/appointments/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/appointments/page.tsx @@ -4,6 +4,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslations } from 'next-intl'; import { appointmentsApi } from '@/lib/api/appointments'; import { patientsApi } from '@/lib/api/patients'; +import { treatmentCatalogApi } from '@/lib/api/treatment-catalog'; +import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; import { useAuth } from '@/lib/hooks/useAuth'; import { canEditAppointments, hasPermission } from '@/components/shared/permissions'; import type { AppointmentColumnProvider, AppointmentRecord } from '@/types/appointment'; @@ -36,6 +38,7 @@ export default function AppointmentsPage() { const [providers, setProviders] = useState([]); const [appointments, setAppointments] = useState([]); + const [treatmentCatalog, setTreatmentCatalog] = useState([]); const [loadingSchedule, setLoadingSchedule] = useState(false); const toast = useToast(); @@ -114,6 +117,13 @@ export default function AppointmentsPage() { void loadSchedule(); }, [loadSchedule]); + useEffect(() => { + void treatmentCatalogApi + .list('appointment') + .then((r) => setTreatmentCatalog(r.data)) + .catch(() => {}); + }, []); + useEffect(() => { const t = setTimeout(() => { void loadPatientsSearch(search); @@ -301,7 +311,7 @@ export default function AppointmentsPage() {
- +
handleSlotClick(startMinute, uid, name)} onAppointmentClick={(apt) => handleAppointmentClick(apt)} @@ -332,6 +343,7 @@ export default function AppointmentsPage() { providerUserId={bookingProviderId} providerName={bookingProviderName} initialStartMinute={bookingStartMinute} + treatmentCatalog={treatmentCatalog} editingAppointment={activeEditingAppointment} onClose={() => { setBookingOpen(false); diff --git a/frontend/src/components/ui/appointments/AppointmentBookingModal.tsx b/frontend/src/components/ui/appointments/AppointmentBookingModal.tsx index 0d38723..bd5a60f 100644 --- a/frontend/src/components/ui/appointments/AppointmentBookingModal.tsx +++ b/frontend/src/components/ui/appointments/AppointmentBookingModal.tsx @@ -6,8 +6,11 @@ import { Button } from '@/components/ui/shared/Button'; import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton'; import { Dropdown } from '@/components/ui/shared/Dropdown'; import type { AppointmentPurpose, AppointmentRecord } from '@/types/appointment'; -import { APPOINTMENT_PURPOSES } from '@/types/appointment'; -import { getPurposeLabel } from '@/components/ui/appointments/appointmentPurposeStyles'; +import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; +import { + DROPDOWN_OPTION_BG, + treatmentTypeColor, +} from '@/components/ui/treatment/treatmentTypeDisplay'; import type { Patient } from '@/types/patient'; import { combineLocalDateAndTime, @@ -31,6 +34,7 @@ interface AppointmentBookingModalProps { endAt: string; purpose: AppointmentPurpose; }) => Promise; + treatmentCatalog: TreatmentCatalogEntry[]; editingAppointment?: AppointmentRecord | null; loading?: boolean; canDelete?: boolean; @@ -38,14 +42,6 @@ interface AppointmentBookingModalProps { deleting?: boolean; } -const PURPOSE_OPTION_COLORS: Record = { - consultation: '#ddd6fe', - filling: '#fed7aa', - endo: '#fecaca', - visit: '#bae6fd', - hygiene: '#d9f99d', -}; - export function AppointmentBookingModal({ open, scheduleDate, @@ -55,6 +51,7 @@ export function AppointmentBookingModal({ initialStartMinute, onClose, onSubmit, + treatmentCatalog, editingAppointment = null, loading = false, canDelete = false, @@ -65,11 +62,14 @@ export function AppointmentBookingModal({ const tCommon = useTranslations('common'); const tPatients = useTranslations('patients'); + const defaultPurpose = treatmentCatalog[0]?.code ?? ''; + const [startTime, setStartTime] = useState('09:00'); const [endTime, setEndTime] = useState('10:00'); - const [purpose, setPurpose] = useState('consultation'); + const [purpose, setPurpose] = useState(defaultPurpose); const [error, setError] = useState(''); - const purposeTextColor = PURPOSE_OPTION_COLORS[purpose]; + const purposeIndex = treatmentCatalog.findIndex((e) => e.code === purpose); + const purposeTextColor = treatmentTypeColor(purpose, purposeIndex < 0 ? 0 : purposeIndex); useEffect(() => { if (!open) { @@ -80,7 +80,7 @@ export function AppointmentBookingModal({ const end = new Date(editingAppointment.endAt); setStartTime(formatTimeForInput(start)); setEndTime(formatTimeForInput(end)); - setPurpose((editingAppointment.purpose as AppointmentPurpose) ?? 'consultation'); + setPurpose(editingAppointment.purpose || defaultPurpose); } else { const start = new Date( scheduleDate.getFullYear(), @@ -103,10 +103,10 @@ export function AppointmentBookingModal({ ); setStartTime(formatTimeForInput(start)); setEndTime(formatTimeForInput(end)); - setPurpose('consultation'); + setPurpose(defaultPurpose); } setError(''); - }, [open, scheduleDate, initialStartMinute, editingAppointment]); + }, [open, scheduleDate, initialStartMinute, editingAppointment, defaultPurpose]); if (!open || !providerUserId) { return null; @@ -224,16 +224,16 @@ export function AppointmentBookingModal({ setPurpose(e.target.value as AppointmentPurpose)} + onChange={(e) => setPurpose(e.target.value)} style={{ color: purposeTextColor }} > - {APPOINTMENT_PURPOSES.map((purposeOption) => ( + {treatmentCatalog.map((entry, index) => ( ))} diff --git a/frontend/src/components/ui/appointments/AppointmentOverlapPopover.tsx b/frontend/src/components/ui/appointments/AppointmentOverlapPopover.tsx index 5d18b14..04b4efd 100644 --- a/frontend/src/components/ui/appointments/AppointmentOverlapPopover.tsx +++ b/frontend/src/components/ui/appointments/AppointmentOverlapPopover.tsx @@ -4,13 +4,15 @@ import { useEffect, useRef } from 'react'; import { useTranslations } from 'next-intl'; import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton'; import { - getPurposeLabel, - purposeStyle, + purposeBannerStyle, + purposeLabel, } from '@/components/ui/appointments/appointmentPurposeStyles'; -import type { AppointmentPurpose, AppointmentRecord } from '@/types/appointment'; +import type { AppointmentRecord } from '@/types/appointment'; +import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; type AppointmentOverlapPopoverProps = { appointments: AppointmentRecord[]; + treatmentCatalog: TreatmentCatalogEntry[]; anchorRect: DOMRect; onSelect: (appointment: AppointmentRecord) => void; onClose: () => void; @@ -25,6 +27,7 @@ function formatTimeRange(apt: AppointmentRecord): string { export function AppointmentOverlapPopover({ appointments, + treatmentCatalog, anchorRect, onSelect, onClose, @@ -82,29 +85,27 @@ export function AppointmentOverlapPopover({
    - {sorted.map((apt) => { - const purpose = apt.purpose as AppointmentPurpose; - return ( -
  • - -
  • - ); - })} + {sorted.map((apt) => ( +
  • + +
  • + ))}
diff --git a/frontend/src/components/ui/appointments/AppointmentScheduleGrid.tsx b/frontend/src/components/ui/appointments/AppointmentScheduleGrid.tsx index 64ca665..5cabee7 100644 --- a/frontend/src/components/ui/appointments/AppointmentScheduleGrid.tsx +++ b/frontend/src/components/ui/appointments/AppointmentScheduleGrid.tsx @@ -18,10 +18,11 @@ import { findOverlapCluster, lanePositionStyles, } from '@/components/appointments/appointmentOverlapLayout'; -import { purposeStyle } from '@/components/ui/appointments/appointmentPurposeStyles'; +import { purposeBannerStyle } from '@/components/ui/appointments/appointmentPurposeStyles'; import { AppointmentOverlapPopover } from '@/components/ui/appointments/AppointmentOverlapPopover'; import { formatMobileForDisplay } from '@/lib/phone'; import { startOfLocalDay } from '@/components/appointments/appointmentTime'; +import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; const HOUR_PX = 80; const SLOT_PX = (HOUR_PX * SCHEDULE_SLOT_MINUTES) / 60; @@ -78,6 +79,7 @@ interface AppointmentScheduleGridProps { day: Date; providers: AppointmentColumnProvider[]; appointments: AppointmentRecord[]; + treatmentCatalog: TreatmentCatalogEntry[]; canBook: boolean; onSlotClick: (startMinute: number, providerUserId: string, providerName: string) => void; onAppointmentClick?: (appointment: AppointmentRecord) => void; @@ -88,6 +90,7 @@ export function AppointmentScheduleGrid({ day, providers, appointments, + treatmentCatalog, canBook, onSlotClick, onAppointmentClick, @@ -320,7 +323,7 @@ export function AppointmentScheduleGrid({ e.currentTarget, ) } - className={`absolute min-h-0 overflow-hidden rounded-[var(--radius-sm)] border pointer-events-auto z-10 flex text-left ${purposeStyle(apt.purpose)} focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/35 ${ + className={`absolute min-h-0 overflow-hidden rounded-[var(--radius-sm)] border pointer-events-auto z-10 flex text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/35 ${ outsideHours ? 'opacity-70 ring-1 ring-amber-500/60' : '' } ${ isUnderOneHour @@ -332,6 +335,7 @@ export function AppointmentScheduleGrid({ height: pos.height, left: lanePos.left, width: lanePos.width, + ...purposeBannerStyle(apt.purpose, treatmentCatalog), }} title={bannerTitle} > @@ -366,6 +370,7 @@ export function AppointmentScheduleGrid({ {overlapPopover && ( { const provider = providers.find((p) => p.userId === apt.providerUserId); diff --git a/frontend/src/components/ui/appointments/AppointmentScheduleLegend.tsx b/frontend/src/components/ui/appointments/AppointmentScheduleLegend.tsx index 8ab970f..041a2e6 100644 --- a/frontend/src/components/ui/appointments/AppointmentScheduleLegend.tsx +++ b/frontend/src/components/ui/appointments/AppointmentScheduleLegend.tsx @@ -1,25 +1,33 @@ 'use client'; import { useTranslations } from 'next-intl'; -import { - APPOINTMENT_PURPOSE_LEGEND_SWATCH, - getPurposeLabel, -} from '@/components/ui/appointments/appointmentPurposeStyles'; -import { APPOINTMENT_PURPOSES } from '@/types/appointment'; +import { purposeSwatchStyle } from '@/components/ui/appointments/appointmentPurposeStyles'; +import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; -export function AppointmentScheduleLegend() { +interface AppointmentScheduleLegendProps { + treatmentCatalog: TreatmentCatalogEntry[]; +} + +export function AppointmentScheduleLegend({ + treatmentCatalog, +}: AppointmentScheduleLegendProps) { const t = useTranslations('appointments'); + if (treatmentCatalog.length === 0) { + return null; + } + return (

{t('legend')}

- {APPOINTMENT_PURPOSES.map((p) => ( -
+ {treatmentCatalog.map((entry) => ( +
- {getPurposeLabel(p, t)} + {entry.label}
))}
diff --git a/frontend/src/components/ui/appointments/appointmentPurposeStyles.ts b/frontend/src/components/ui/appointments/appointmentPurposeStyles.ts index 813befa..71109e5 100644 --- a/frontend/src/components/ui/appointments/appointmentPurposeStyles.ts +++ b/frontend/src/components/ui/appointments/appointmentPurposeStyles.ts @@ -1,46 +1,39 @@ -import type { AppointmentPurpose } from '@/types/appointment'; +import type { CSSProperties } from 'react'; +import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; +import { + treatmentTypeBannerStyle, + treatmentTypeLabelFromCatalog, + treatmentTypeSwatchStyle, +} from '@/components/ui/treatment/treatmentTypeDisplay'; -export const APPOINTMENT_PURPOSE_LABEL_KEYS = { - consultation: 'purposeConsultation', - filling: 'purposeFilling', - endo: 'purposeEndo', - visit: 'purposeVisit', - hygiene: 'purposeHygiene', -} as const satisfies Record; +/** + * Appointment purposes are treatment-type codes. Labels and colors now come from + * the shared treatment catalog + palette so the appointment and treatment + * features stay in sync. These helpers adapt the shared palette to the appointment + * components' call sites. + */ -export type AppointmentPurposeLabelKey = - (typeof APPOINTMENT_PURPOSE_LABEL_KEYS)[AppointmentPurpose]; - -export type AppointmentPurposeTranslate = (key: AppointmentPurposeLabelKey) => string; - -export function getPurposeLabel( - purpose: AppointmentPurpose, - t: AppointmentPurposeTranslate, +export function purposeLabel( + purpose: string, + catalog: TreatmentCatalogEntry[], ): string { - const key = APPOINTMENT_PURPOSE_LABEL_KEYS[purpose]; - return key ? t(key) : purpose; + return treatmentTypeLabelFromCatalog(purpose, catalog); } -/** Background + border for blocks / legend (matches reference palette). */ -export const APPOINTMENT_PURPOSE_STYLES: Record = { - consultation: - 'bg-purpose-consultation-bg border-purpose-consultation-border text-purpose-consultation-fg', - filling: 'bg-purpose-filling-bg border-purpose-filling-border text-purpose-filling-fg', - endo: 'bg-purpose-endo-bg border-purpose-endo-border text-purpose-endo-fg', - visit: 'bg-purpose-visit-bg border-purpose-visit-border text-purpose-visit-fg', - hygiene: 'bg-purpose-hygiene-bg border-purpose-hygiene-border text-purpose-hygiene-fg', -}; - -export function purposeStyle(purpose: string): string { - const p = purpose as AppointmentPurpose; - return APPOINTMENT_PURPOSE_STYLES[p] ?? 'bg-surface-elevated border-border text-text-secondary'; +/** Inline style for a colored appointment banner/block. */ +export function purposeBannerStyle( + purpose: string, + catalog: TreatmentCatalogEntry[], +): CSSProperties { + const index = catalog.findIndex((e) => e.code === purpose); + return treatmentTypeBannerStyle(purpose, index); } -/** Small swatch for legend (background + border only). */ -export const APPOINTMENT_PURPOSE_LEGEND_SWATCH: Record = { - consultation: 'bg-violet-500/85 border-violet-400/75', - filling: 'bg-orange-500/85 border-orange-400/75', - endo: 'bg-red-500/85 border-red-400/75', - visit: 'bg-sky-500/85 border-sky-400/75', - hygiene: 'bg-lime-500/80 border-lime-400/70', -}; +/** Inline style for a small legend swatch. */ +export function purposeSwatchStyle( + purpose: string, + catalog: TreatmentCatalogEntry[], +): CSSProperties { + const index = catalog.findIndex((e) => e.code === purpose); + return treatmentTypeSwatchStyle(purpose, index); +} diff --git a/frontend/src/components/ui/treatment/AppointmentsStrip.tsx b/frontend/src/components/ui/treatment/AppointmentsStrip.tsx index 527b9ee..8326448 100644 --- a/frontend/src/components/ui/treatment/AppointmentsStrip.tsx +++ b/frontend/src/components/ui/treatment/AppointmentsStrip.tsx @@ -2,11 +2,13 @@ import { useTranslations } from 'next-intl'; import { CalendarDays } from 'lucide-react'; -import { purposeStyle } from '@/components/ui/appointments/appointmentPurposeStyles'; import { Card } from '@/components/ui/shared/Card'; import { ScheduleDayPicker } from '@/components/ui/shared/ScheduleDayPicker'; import { startOfLocalDay } from '@/components/appointments/appointmentTime'; -import { treatmentTypeLabelFromCatalog } from '@/components/ui/treatment/treatmentTypeDisplay'; +import { + treatmentTypeBannerStyle, + treatmentTypeLabelFromCatalog, +} from '@/components/ui/treatment/treatmentTypeDisplay'; import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; import type { TreatmentAppointment } from '@/types/treatment'; @@ -91,8 +93,8 @@ export function AppointmentsStrip({ hour: 'numeric', minute: '2-digit', })}`; - const palette = purposeStyle(a.purpose); const purposeLabel = treatmentTypeLabelFromCatalog(a.purpose, treatmentCatalog); + const purposeIndex = treatmentCatalog.findIndex((e) => e.code === a.purpose); return ( onSelectAppointment(a.id)} padding="none" + style={treatmentTypeBannerStyle(a.purpose, purposeIndex < 0 ? 0 : purposeIndex)} className={` text-left rounded-[var(--radius-sm)] px-3 py-2 min-w-[200px] max-w-[280px] transition-shadow min-h-[52px] focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/45 - ${palette} ${sel ? 'ring-2 ring-primary ring-offset-2 ring-offset-background-secondary shadow-[inset_0_1px_0_rgba(255,255,255,0.06)]' : 'hover:brightness-110'} `} > diff --git a/frontend/src/components/ui/treatment/TreatmentTypeBadge.tsx b/frontend/src/components/ui/treatment/TreatmentTypeBadge.tsx index 8f8cc55..61b5b2a 100644 --- a/frontend/src/components/ui/treatment/TreatmentTypeBadge.tsx +++ b/frontend/src/components/ui/treatment/TreatmentTypeBadge.tsx @@ -1,7 +1,9 @@ 'use client'; -import { purposeStyle } from '@/components/ui/appointments/appointmentPurposeStyles'; -import { formatCodeAsLabel } from '@/components/ui/treatment/treatmentTypeDisplay'; +import { + formatCodeAsLabel, + treatmentTypeBannerStyle, +} from '@/components/ui/treatment/treatmentTypeDisplay'; interface TreatmentTypeBadgeProps { type: string; @@ -14,7 +16,8 @@ export function TreatmentTypeBadge({ type, label, className = '' }: TreatmentTyp return ( {display} diff --git a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx index b38cb1f..d4ee63f 100644 --- a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx +++ b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx @@ -242,6 +242,10 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor const [orgs, setOrgs] = useState([]); const [labDependentCodes, setLabDependentCodes] = useState>(new Set()); const [treatmentCatalog, setTreatmentCatalog] = useState([]); + const treatmentDropdownCatalog = useMemo( + () => treatmentCatalog.filter((entry) => entry.availableInTreatment), + [treatmentCatalog], + ); const [details, setDetails] = useState(() => [newDetail()]); const [labCaseDrafts, setLabCaseDrafts] = useState([]); @@ -984,7 +988,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor onDetailsChange={setDetails} isDetailLocked={isDetailLocked} labDependentCodes={labDependentCodes} - treatmentCatalog={treatmentCatalog} + treatmentCatalog={treatmentDropdownCatalog} disabled={!canEditTreatmentForDay} canEdit={canEdit} saveStatus={saveStatus} diff --git a/frontend/src/components/ui/treatment/treatmentTypeDisplay.ts b/frontend/src/components/ui/treatment/treatmentTypeDisplay.ts index bf48185..bcf90eb 100644 --- a/frontend/src/components/ui/treatment/treatmentTypeDisplay.ts +++ b/frontend/src/components/ui/treatment/treatmentTypeDisplay.ts @@ -1,9 +1,19 @@ +import type { CSSProperties } from 'react'; import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; +/** + * Single source of truth for treatment-type colors across the app + * (treatment detail dropdown, appointment booking dropdown, appointment legend, + * appointment schedule banners, and the treatment feature's appointment cards). + * + * There is no universal dental type→color standard (only status-based blue/red + * conventions), so this is a curated pastel palette. Extend it as new treatment + * types are added; unknown codes fall back to a rotating pastel set by index. + */ const TREATMENT_TYPE_COLORS: Record = { restoration: '#fed7aa', specialized_restoration: '#fdba74', - radiography: '#e2e8f0', + radiography: '#cbd5e1', endo: '#fecaca', surgery: '#fca5a5', prosthesis: '#c4b5fd', @@ -11,16 +21,43 @@ const TREATMENT_TYPE_COLORS: Record = { orthodontics: '#93c5fd', perio: '#86efac', pediatrics: '#fde68a', - extraction: '#f87171', + extraction: '#f9a8d4', clinic_visit: '#bae6fd', + continue_treatment: '#99f6e4', }; -const FALLBACK_COLORS = ['#ddd6fe', '#fed7aa', '#fecaca', '#bae6fd', '#d9f99d']; +const FALLBACK_COLORS = ['#ddd6fe', '#fed7aa', '#fecaca', '#bae6fd', '#d9f99d', '#fbcfe8']; + +/** Dark ink that stays readable on every pastel in the palette. */ +const BANNER_INK = '#14253d'; +/** Dark background used behind pastel option text in native