improvement: all clinic side ui components related to treatment types updated based on the new real world data.

This commit is contained in:
2026-07-07 13:13:04 +03:30
parent 667b08ed0c
commit ed7e7b1d8f
20 changed files with 321 additions and 148 deletions

View File

@@ -7,7 +7,17 @@ export type CatalogTranslationSeed = {
label: string; 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: 'restoration', labDependent: false, sortOrder: 1 },
{ code: 'specialized_restoration', labDependent: false, sortOrder: 2 }, { code: 'specialized_restoration', labDependent: false, sortOrder: 2 },
{ code: 'radiography', labDependent: false, sortOrder: 3 }, { code: 'radiography', labDependent: false, sortOrder: 3 },
@@ -19,11 +29,23 @@ export const TREATMENT_TYPES = [
{ code: 'perio', labDependent: false, sortOrder: 9 }, { code: 'perio', labDependent: false, sortOrder: 9 },
{ code: 'pediatrics', labDependent: false, sortOrder: 10 }, { code: 'pediatrics', labDependent: false, sortOrder: 10 },
{ code: 'extraction', labDependent: false, sortOrder: 11 }, { 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; ] as const;
/** Legacy codes kept for historical rows; hidden from catalog. */ /** 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: 'consultation', labDependent: false, sortOrder: 99 },
{ code: 'filling', labDependent: false, sortOrder: 100 }, { code: 'filling', labDependent: false, sortOrder: 100 },
{ code: 'visit', labDependent: false, sortOrder: 101 }, { code: 'visit', labDependent: false, sortOrder: 101 },
@@ -207,7 +229,8 @@ const TREATMENT_LABELS: Record<string, Record<string, string>> = {
perio: { en: 'Perio', fa: 'پریو', nl: 'Paro' }, perio: { en: 'Perio', fa: 'پریو', nl: 'Paro' },
pediatrics: { en: 'Pediatrics', fa: 'اطفال', nl: 'Kinderen' }, pediatrics: { en: 'Pediatrics', fa: 'اطفال', nl: 'Kinderen' },
extraction: { en: 'Extraction', fa: 'کشیدن', nl: 'Extractie' }, 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' }, consultation: { en: 'Consultation', fa: 'مشاوره', nl: 'Consult' },
filling: { en: 'Filling', fa: 'پر کردن', nl: 'Vulling' }, filling: { en: 'Filling', fa: 'پر کردن', nl: 'Vulling' },
visit: { en: 'Visit', fa: 'ویزیت', nl: 'Bezoek' }, visit: { en: 'Visit', fa: 'ویزیت', nl: 'Bezoek' },

View File

@@ -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;

View File

@@ -1,6 +1,9 @@
/** /**
* Dev-only: truncate treatment and lab case data (preserves catalog tables). * Dev-only: truncate treatment and lab case data (preserves catalog tables).
* Usage: npx ts-node prisma/reset-treatment-data.ts * 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 { PrismaClient } from '@prisma/client';
import { config } from 'dotenv'; import { config } from 'dotenv';
@@ -16,17 +19,44 @@ if (process.env.NODE_ENV === 'production') {
const prisma = new PrismaClient(); 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<boolean> {
const rows = await prisma.$queryRawUnsafe<Array<{ exists: string | null }>>(
`SELECT to_regclass('public."${table}"')::text AS exists`,
);
return rows[0]?.exists != null;
}
async function main() { async function main() {
console.log('Truncating treatment and lab case data...'); console.log('Truncating treatment and lab case data...');
await prisma.$executeRawUnsafe('TRUNCATE TABLE "lab_case_tasks" CASCADE'); const existing: string[] = [];
await prisma.$executeRawUnsafe('TRUNCATE TABLE "lab_case_sends" CASCADE'); for (const table of TABLES_IN_ORDER) {
await prisma.$executeRawUnsafe('TRUNCATE TABLE "lab_case_tooth_prosthesis" CASCADE'); if (await tableExists(table)) {
await prisma.$executeRawUnsafe('TRUNCATE TABLE "lab_case_details" CASCADE'); existing.push(table);
await prisma.$executeRawUnsafe('TRUNCATE TABLE "lab_cases" CASCADE'); } else {
await prisma.$executeRawUnsafe('TRUNCATE TABLE "treatment_detail_attachments" CASCADE'); console.log(` - skipping "${table}" (does not exist yet)`);
await prisma.$executeRawUnsafe('TRUNCATE TABLE "treatment_details" CASCADE'); }
await prisma.$executeRawUnsafe('TRUNCATE TABLE "treatments" CASCADE'); }
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.'); console.log('Done.');
} }

View File

@@ -224,11 +224,13 @@ model LabCaseSend {
} }
model TreatmentType { model TreatmentType {
id String @id @default(uuid()) id String @id @default(uuid())
code String @unique code String @unique
labDependent Boolean @default(false) labDependent Boolean @default(false)
sortOrder Int @default(0) sortOrder Int @default(0)
isActive Boolean @default(true) isActive Boolean @default(true)
availableInAppointments Boolean @default(true)
availableInTreatment Boolean @default(true)
@@map("treatment_types") @@map("treatment_types")
} }

View File

@@ -138,12 +138,16 @@ async function main() {
for (const type of [...TREATMENT_TYPES, ...LEGACY_TREATMENT_TYPES]) { for (const type of [...TREATMENT_TYPES, ...LEGACY_TREATMENT_TYPES]) {
const isActive = TREATMENT_TYPES.some((t) => t.code === type.code); const isActive = TREATMENT_TYPES.some((t) => t.code === type.code);
const availableInAppointments = type.availableInAppointments ?? true;
const availableInTreatment = type.availableInTreatment ?? true;
await prisma.treatmentType.upsert({ await prisma.treatmentType.upsert({
where: { code: type.code }, where: { code: type.code },
update: { update: {
labDependent: type.labDependent, labDependent: type.labDependent,
sortOrder: type.sortOrder, sortOrder: type.sortOrder,
isActive, isActive,
availableInAppointments,
availableInTreatment,
}, },
create: { create: {
id: randomUUID(), id: randomUUID(),
@@ -151,6 +155,8 @@ async function main() {
labDependent: type.labDependent, labDependent: type.labDependent,
sortOrder: type.sortOrder, sortOrder: type.sortOrder,
isActive, isActive,
availableInAppointments,
availableInTreatment,
}, },
}); });
} }

View File

@@ -1,7 +1,10 @@
import { Controller, Get, Req, UseGuards } from '@nestjs/common'; import { Controller, Get, Query, Req, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { TreatmentCatalogService } from './treatment-catalog.service'; import {
TreatmentCatalogContext,
TreatmentCatalogService,
} from './treatment-catalog.service';
@ApiTags('treatment-catalog') @ApiTags('treatment-catalog')
@ApiBearerAuth('JWT-auth') @ApiBearerAuth('JWT-auth')
@@ -12,8 +15,24 @@ export class TreatmentCatalogController {
@Get() @Get()
@ApiOperation({ summary: 'List active treatment types with localized labels' }) @ApiOperation({ summary: 'List active treatment types with localized labels' })
async list(@Req() req: { user?: { language?: string | null } }) { @ApiQuery({
const data = await this.treatmentCatalogService.list(req.user?.language); 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 }; return { success: true, data };
} }
} }

View File

@@ -7,12 +7,16 @@ import {
normalizeCatalogLocale, normalizeCatalogLocale,
} from '../catalog/catalog-label.service'; } from '../catalog/catalog-label.service';
export type TreatmentCatalogContext = 'appointment' | 'treatment';
export type TreatmentTypeCatalogEntry = { export type TreatmentTypeCatalogEntry = {
id: string; id: string;
code: string; code: string;
labDependent: boolean; labDependent: boolean;
sortOrder: number; sortOrder: number;
label: string; label: string;
availableInAppointments: boolean;
availableInTreatment: boolean;
}; };
@Injectable() @Injectable()
@@ -33,7 +37,14 @@ export class TreatmentCatalogService implements OnModuleInit {
const rows = await this.prisma.treatmentType.findMany({ const rows = await this.prisma.treatmentType.findMany({
where: { isActive: true }, where: { isActive: true },
orderBy: [{ sortOrder: 'asc' }, { code: 'asc' }], 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( this.byCode = new Map(
@@ -45,17 +56,26 @@ export class TreatmentCatalogService implements OnModuleInit {
labDependent: row.labDependent, labDependent: row.labDependent,
sortOrder: row.sortOrder, sortOrder: row.sortOrder,
label: row.code, label: row.code,
availableInAppointments: row.availableInAppointments,
availableInTreatment: row.availableInTreatment,
}, },
]), ]),
); );
this.loaded = true; this.loaded = true;
} }
async list(localeInput?: string | null): Promise<TreatmentTypeCatalogEntry[]> { async list(
localeInput?: string | null,
context?: TreatmentCatalogContext | null,
): Promise<TreatmentTypeCatalogEntry[]> {
await this.ensureLabels(localeInput); await this.ensureLabels(localeInput);
return [...this.byCode.values()].sort( return [...this.byCode.values()]
(a, b) => a.sortOrder - b.sortOrder || a.code.localeCompare(b.code), .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) { private async ensureLabels(localeInput?: string | null) {

View File

@@ -4,6 +4,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { appointmentsApi } from '@/lib/api/appointments'; import { appointmentsApi } from '@/lib/api/appointments';
import { patientsApi } from '@/lib/api/patients'; 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 { useAuth } from '@/lib/hooks/useAuth';
import { canEditAppointments, hasPermission } from '@/components/shared/permissions'; import { canEditAppointments, hasPermission } from '@/components/shared/permissions';
import type { AppointmentColumnProvider, AppointmentRecord } from '@/types/appointment'; import type { AppointmentColumnProvider, AppointmentRecord } from '@/types/appointment';
@@ -36,6 +38,7 @@ export default function AppointmentsPage() {
const [providers, setProviders] = useState<AppointmentColumnProvider[]>([]); const [providers, setProviders] = useState<AppointmentColumnProvider[]>([]);
const [appointments, setAppointments] = useState<AppointmentRecord[]>([]); const [appointments, setAppointments] = useState<AppointmentRecord[]>([]);
const [treatmentCatalog, setTreatmentCatalog] = useState<TreatmentCatalogEntry[]>([]);
const [loadingSchedule, setLoadingSchedule] = useState(false); const [loadingSchedule, setLoadingSchedule] = useState(false);
const toast = useToast(); const toast = useToast();
@@ -114,6 +117,13 @@ export default function AppointmentsPage() {
void loadSchedule(); void loadSchedule();
}, [loadSchedule]); }, [loadSchedule]);
useEffect(() => {
void treatmentCatalogApi
.list('appointment')
.then((r) => setTreatmentCatalog(r.data))
.catch(() => {});
}, []);
useEffect(() => { useEffect(() => {
const t = setTimeout(() => { const t = setTimeout(() => {
void loadPatientsSearch(search); void loadPatientsSearch(search);
@@ -301,7 +311,7 @@ export default function AppointmentsPage() {
</div> </div>
<div className="xl:col-span-2 space-y-4"> <div className="xl:col-span-2 space-y-4">
<AppointmentScheduleLegend /> <AppointmentScheduleLegend treatmentCatalog={treatmentCatalog} />
<div className="flex flex-col sm:flex-row sm:items-end gap-4 sm:justify-between"> <div className="flex flex-col sm:flex-row sm:items-end gap-4 sm:justify-between">
<ScheduleDayPicker <ScheduleDayPicker
@@ -317,6 +327,7 @@ export default function AppointmentsPage() {
day={scheduleDate} day={scheduleDate}
providers={providers} providers={providers}
appointments={appointments} appointments={appointments}
treatmentCatalog={treatmentCatalog}
canBook={canManageAppointments && !isViewingPastDay} canBook={canManageAppointments && !isViewingPastDay}
onSlotClick={(startMinute, uid, name) => handleSlotClick(startMinute, uid, name)} onSlotClick={(startMinute, uid, name) => handleSlotClick(startMinute, uid, name)}
onAppointmentClick={(apt) => handleAppointmentClick(apt)} onAppointmentClick={(apt) => handleAppointmentClick(apt)}
@@ -332,6 +343,7 @@ export default function AppointmentsPage() {
providerUserId={bookingProviderId} providerUserId={bookingProviderId}
providerName={bookingProviderName} providerName={bookingProviderName}
initialStartMinute={bookingStartMinute} initialStartMinute={bookingStartMinute}
treatmentCatalog={treatmentCatalog}
editingAppointment={activeEditingAppointment} editingAppointment={activeEditingAppointment}
onClose={() => { onClose={() => {
setBookingOpen(false); setBookingOpen(false);

View File

@@ -6,8 +6,11 @@ import { Button } from '@/components/ui/shared/Button';
import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton'; import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
import { Dropdown } from '@/components/ui/shared/Dropdown'; import { Dropdown } from '@/components/ui/shared/Dropdown';
import type { AppointmentPurpose, AppointmentRecord } from '@/types/appointment'; import type { AppointmentPurpose, AppointmentRecord } from '@/types/appointment';
import { APPOINTMENT_PURPOSES } from '@/types/appointment'; import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
import { getPurposeLabel } from '@/components/ui/appointments/appointmentPurposeStyles'; import {
DROPDOWN_OPTION_BG,
treatmentTypeColor,
} from '@/components/ui/treatment/treatmentTypeDisplay';
import type { Patient } from '@/types/patient'; import type { Patient } from '@/types/patient';
import { import {
combineLocalDateAndTime, combineLocalDateAndTime,
@@ -31,6 +34,7 @@ interface AppointmentBookingModalProps {
endAt: string; endAt: string;
purpose: AppointmentPurpose; purpose: AppointmentPurpose;
}) => Promise<void>; }) => Promise<void>;
treatmentCatalog: TreatmentCatalogEntry[];
editingAppointment?: AppointmentRecord | null; editingAppointment?: AppointmentRecord | null;
loading?: boolean; loading?: boolean;
canDelete?: boolean; canDelete?: boolean;
@@ -38,14 +42,6 @@ interface AppointmentBookingModalProps {
deleting?: boolean; deleting?: boolean;
} }
const PURPOSE_OPTION_COLORS: Record<AppointmentPurpose, string> = {
consultation: '#ddd6fe',
filling: '#fed7aa',
endo: '#fecaca',
visit: '#bae6fd',
hygiene: '#d9f99d',
};
export function AppointmentBookingModal({ export function AppointmentBookingModal({
open, open,
scheduleDate, scheduleDate,
@@ -55,6 +51,7 @@ export function AppointmentBookingModal({
initialStartMinute, initialStartMinute,
onClose, onClose,
onSubmit, onSubmit,
treatmentCatalog,
editingAppointment = null, editingAppointment = null,
loading = false, loading = false,
canDelete = false, canDelete = false,
@@ -65,11 +62,14 @@ export function AppointmentBookingModal({
const tCommon = useTranslations('common'); const tCommon = useTranslations('common');
const tPatients = useTranslations('patients'); const tPatients = useTranslations('patients');
const defaultPurpose = treatmentCatalog[0]?.code ?? '';
const [startTime, setStartTime] = useState('09:00'); const [startTime, setStartTime] = useState('09:00');
const [endTime, setEndTime] = useState('10:00'); const [endTime, setEndTime] = useState('10:00');
const [purpose, setPurpose] = useState<AppointmentPurpose>('consultation'); const [purpose, setPurpose] = useState<AppointmentPurpose>(defaultPurpose);
const [error, setError] = useState(''); 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(() => { useEffect(() => {
if (!open) { if (!open) {
@@ -80,7 +80,7 @@ export function AppointmentBookingModal({
const end = new Date(editingAppointment.endAt); const end = new Date(editingAppointment.endAt);
setStartTime(formatTimeForInput(start)); setStartTime(formatTimeForInput(start));
setEndTime(formatTimeForInput(end)); setEndTime(formatTimeForInput(end));
setPurpose((editingAppointment.purpose as AppointmentPurpose) ?? 'consultation'); setPurpose(editingAppointment.purpose || defaultPurpose);
} else { } else {
const start = new Date( const start = new Date(
scheduleDate.getFullYear(), scheduleDate.getFullYear(),
@@ -103,10 +103,10 @@ export function AppointmentBookingModal({
); );
setStartTime(formatTimeForInput(start)); setStartTime(formatTimeForInput(start));
setEndTime(formatTimeForInput(end)); setEndTime(formatTimeForInput(end));
setPurpose('consultation'); setPurpose(defaultPurpose);
} }
setError(''); setError('');
}, [open, scheduleDate, initialStartMinute, editingAppointment]); }, [open, scheduleDate, initialStartMinute, editingAppointment, defaultPurpose]);
if (!open || !providerUserId) { if (!open || !providerUserId) {
return null; return null;
@@ -224,16 +224,16 @@ export function AppointmentBookingModal({
<Dropdown <Dropdown
label={t('purposeLabel')} label={t('purposeLabel')}
value={purpose} value={purpose}
onChange={(e) => setPurpose(e.target.value as AppointmentPurpose)} onChange={(e) => setPurpose(e.target.value)}
style={{ color: purposeTextColor }} style={{ color: purposeTextColor }}
> >
{APPOINTMENT_PURPOSES.map((purposeOption) => ( {treatmentCatalog.map((entry, index) => (
<option <option
key={purposeOption} key={entry.code}
value={purposeOption} value={entry.code}
style={{ color: PURPOSE_OPTION_COLORS[purposeOption], backgroundColor: '#14253d' }} style={{ color: treatmentTypeColor(entry.code, index), backgroundColor: DROPDOWN_OPTION_BG }}
> >
{getPurposeLabel(purposeOption, t)} {entry.label}
</option> </option>
))} ))}
</Dropdown> </Dropdown>

View File

@@ -4,13 +4,15 @@ import { useEffect, useRef } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton'; import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
import { import {
getPurposeLabel, purposeBannerStyle,
purposeStyle, purposeLabel,
} from '@/components/ui/appointments/appointmentPurposeStyles'; } 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 = { type AppointmentOverlapPopoverProps = {
appointments: AppointmentRecord[]; appointments: AppointmentRecord[];
treatmentCatalog: TreatmentCatalogEntry[];
anchorRect: DOMRect; anchorRect: DOMRect;
onSelect: (appointment: AppointmentRecord) => void; onSelect: (appointment: AppointmentRecord) => void;
onClose: () => void; onClose: () => void;
@@ -25,6 +27,7 @@ function formatTimeRange(apt: AppointmentRecord): string {
export function AppointmentOverlapPopover({ export function AppointmentOverlapPopover({
appointments, appointments,
treatmentCatalog,
anchorRect, anchorRect,
onSelect, onSelect,
onClose, onClose,
@@ -82,29 +85,27 @@ export function AppointmentOverlapPopover({
<DialogCloseButton onClick={onClose} /> <DialogCloseButton onClick={onClose} />
</div> </div>
<ul className="space-y-1.5 max-h-[min(16rem,50vh)] overflow-y-auto"> <ul className="space-y-1.5 max-h-[min(16rem,50vh)] overflow-y-auto">
{sorted.map((apt) => { {sorted.map((apt) => (
const purpose = apt.purpose as AppointmentPurpose; <li key={apt.id}>
return ( <button
<li key={apt.id}> type="button"
<button onClick={() => {
type="button" onSelect(apt);
onClick={() => { onClose();
onSelect(apt); }}
onClose(); className="w-full rounded-[var(--radius-sm)] border px-2.5 py-2 text-left transition-colors hover:brightness-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/35"
}} style={purposeBannerStyle(apt.purpose, treatmentCatalog)}
className={`w-full rounded-[var(--radius-sm)] border px-2.5 py-2 text-left transition-colors hover:brightness-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/35 ${purposeStyle(apt.purpose)}`} >
> <p className="text-xs font-medium truncate">
<p className="text-xs font-medium truncate"> {apt.patient.firstName} {apt.patient.lastName}
{apt.patient.firstName} {apt.patient.lastName} </p>
</p> <p className="text-[11px] opacity-90 tabular-nums">{formatTimeRange(apt)}</p>
<p className="text-[11px] opacity-90 tabular-nums">{formatTimeRange(apt)}</p> <p className="text-[10px] opacity-80 truncate">
<p className="text-[10px] opacity-80 truncate"> {purposeLabel(apt.purpose, treatmentCatalog)}
{getPurposeLabel(purpose, t) ?? apt.purpose} </p>
</p> </button>
</button> </li>
</li> ))}
);
})}
</ul> </ul>
</div> </div>
</div> </div>

View File

@@ -18,10 +18,11 @@ import {
findOverlapCluster, findOverlapCluster,
lanePositionStyles, lanePositionStyles,
} from '@/components/appointments/appointmentOverlapLayout'; } 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 { AppointmentOverlapPopover } from '@/components/ui/appointments/AppointmentOverlapPopover';
import { formatMobileForDisplay } from '@/lib/phone'; import { formatMobileForDisplay } from '@/lib/phone';
import { startOfLocalDay } from '@/components/appointments/appointmentTime'; import { startOfLocalDay } from '@/components/appointments/appointmentTime';
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
const HOUR_PX = 80; const HOUR_PX = 80;
const SLOT_PX = (HOUR_PX * SCHEDULE_SLOT_MINUTES) / 60; const SLOT_PX = (HOUR_PX * SCHEDULE_SLOT_MINUTES) / 60;
@@ -78,6 +79,7 @@ interface AppointmentScheduleGridProps {
day: Date; day: Date;
providers: AppointmentColumnProvider[]; providers: AppointmentColumnProvider[];
appointments: AppointmentRecord[]; appointments: AppointmentRecord[];
treatmentCatalog: TreatmentCatalogEntry[];
canBook: boolean; canBook: boolean;
onSlotClick: (startMinute: number, providerUserId: string, providerName: string) => void; onSlotClick: (startMinute: number, providerUserId: string, providerName: string) => void;
onAppointmentClick?: (appointment: AppointmentRecord) => void; onAppointmentClick?: (appointment: AppointmentRecord) => void;
@@ -88,6 +90,7 @@ export function AppointmentScheduleGrid({
day, day,
providers, providers,
appointments, appointments,
treatmentCatalog,
canBook, canBook,
onSlotClick, onSlotClick,
onAppointmentClick, onAppointmentClick,
@@ -320,7 +323,7 @@ export function AppointmentScheduleGrid({
e.currentTarget, 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' : '' outsideHours ? 'opacity-70 ring-1 ring-amber-500/60' : ''
} ${ } ${
isUnderOneHour isUnderOneHour
@@ -332,6 +335,7 @@ export function AppointmentScheduleGrid({
height: pos.height, height: pos.height,
left: lanePos.left, left: lanePos.left,
width: lanePos.width, width: lanePos.width,
...purposeBannerStyle(apt.purpose, treatmentCatalog),
}} }}
title={bannerTitle} title={bannerTitle}
> >
@@ -366,6 +370,7 @@ export function AppointmentScheduleGrid({
{overlapPopover && ( {overlapPopover && (
<AppointmentOverlapPopover <AppointmentOverlapPopover
appointments={overlapPopover.appointments} appointments={overlapPopover.appointments}
treatmentCatalog={treatmentCatalog}
anchorRect={overlapPopover.anchorRect} anchorRect={overlapPopover.anchorRect}
onSelect={(apt) => { onSelect={(apt) => {
const provider = providers.find((p) => p.userId === apt.providerUserId); const provider = providers.find((p) => p.userId === apt.providerUserId);

View File

@@ -1,25 +1,33 @@
'use client'; 'use client';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { import { purposeSwatchStyle } from '@/components/ui/appointments/appointmentPurposeStyles';
APPOINTMENT_PURPOSE_LEGEND_SWATCH, import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
getPurposeLabel,
} from '@/components/ui/appointments/appointmentPurposeStyles';
import { APPOINTMENT_PURPOSES } from '@/types/appointment';
export function AppointmentScheduleLegend() { interface AppointmentScheduleLegendProps {
treatmentCatalog: TreatmentCatalogEntry[];
}
export function AppointmentScheduleLegend({
treatmentCatalog,
}: AppointmentScheduleLegendProps) {
const t = useTranslations('appointments'); const t = useTranslations('appointments');
if (treatmentCatalog.length === 0) {
return null;
}
return ( return (
<div className="surface-panel px-4 py-3"> <div className="surface-panel px-4 py-3">
<p className="text-xs font-medium text-text-secondary mb-2">{t('legend')}</p> <p className="text-xs font-medium text-text-secondary mb-2">{t('legend')}</p>
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
{APPOINTMENT_PURPOSES.map((p) => ( {treatmentCatalog.map((entry) => (
<div key={p} className="flex items-center gap-1.5 text-xs text-text-secondary"> <div key={entry.code} className="flex items-center gap-1.5 text-xs text-text-secondary">
<span <span
className={`inline-block h-3 w-3 rounded-sm border ${APPOINTMENT_PURPOSE_LEGEND_SWATCH[p]}`} className="inline-block h-3 w-3 rounded-sm border"
style={purposeSwatchStyle(entry.code, treatmentCatalog)}
/> />
{getPurposeLabel(p, t)} {entry.label}
</div> </div>
))} ))}
</div> </div>

View File

@@ -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', * Appointment purposes are treatment-type codes. Labels and colors now come from
filling: 'purposeFilling', * the shared treatment catalog + palette so the appointment and treatment
endo: 'purposeEndo', * features stay in sync. These helpers adapt the shared palette to the appointment
visit: 'purposeVisit', * components' call sites.
hygiene: 'purposeHygiene', */
} as const satisfies Record<AppointmentPurpose, string>;
export type AppointmentPurposeLabelKey = export function purposeLabel(
(typeof APPOINTMENT_PURPOSE_LABEL_KEYS)[AppointmentPurpose]; purpose: string,
catalog: TreatmentCatalogEntry[],
export type AppointmentPurposeTranslate = (key: AppointmentPurposeLabelKey) => string;
export function getPurposeLabel(
purpose: AppointmentPurpose,
t: AppointmentPurposeTranslate,
): string { ): string {
const key = APPOINTMENT_PURPOSE_LABEL_KEYS[purpose]; return treatmentTypeLabelFromCatalog(purpose, catalog);
return key ? t(key) : purpose;
} }
/** Background + border for blocks / legend (matches reference palette). */ /** Inline style for a colored appointment banner/block. */
export const APPOINTMENT_PURPOSE_STYLES: Record<AppointmentPurpose, string> = { export function purposeBannerStyle(
consultation: purpose: string,
'bg-purpose-consultation-bg border-purpose-consultation-border text-purpose-consultation-fg', catalog: TreatmentCatalogEntry[],
filling: 'bg-purpose-filling-bg border-purpose-filling-border text-purpose-filling-fg', ): CSSProperties {
endo: 'bg-purpose-endo-bg border-purpose-endo-border text-purpose-endo-fg', const index = catalog.findIndex((e) => e.code === purpose);
visit: 'bg-purpose-visit-bg border-purpose-visit-border text-purpose-visit-fg', return treatmentTypeBannerStyle(purpose, index);
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';
} }
/** Small swatch for legend (background + border only). */ /** Inline style for a small legend swatch. */
export const APPOINTMENT_PURPOSE_LEGEND_SWATCH: Record<AppointmentPurpose, string> = { export function purposeSwatchStyle(
consultation: 'bg-violet-500/85 border-violet-400/75', purpose: string,
filling: 'bg-orange-500/85 border-orange-400/75', catalog: TreatmentCatalogEntry[],
endo: 'bg-red-500/85 border-red-400/75', ): CSSProperties {
visit: 'bg-sky-500/85 border-sky-400/75', const index = catalog.findIndex((e) => e.code === purpose);
hygiene: 'bg-lime-500/80 border-lime-400/70', return treatmentTypeSwatchStyle(purpose, index);
}; }

View File

@@ -2,11 +2,13 @@
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { CalendarDays } from 'lucide-react'; import { CalendarDays } from 'lucide-react';
import { purposeStyle } from '@/components/ui/appointments/appointmentPurposeStyles';
import { Card } from '@/components/ui/shared/Card'; import { Card } from '@/components/ui/shared/Card';
import { ScheduleDayPicker } from '@/components/ui/shared/ScheduleDayPicker'; import { ScheduleDayPicker } from '@/components/ui/shared/ScheduleDayPicker';
import { startOfLocalDay } from '@/components/appointments/appointmentTime'; 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 { TreatmentCatalogEntry } from '@/types/treatment-catalog';
import type { TreatmentAppointment } from '@/types/treatment'; import type { TreatmentAppointment } from '@/types/treatment';
@@ -91,8 +93,8 @@ export function AppointmentsStrip({
hour: 'numeric', hour: 'numeric',
minute: '2-digit', minute: '2-digit',
})}`; })}`;
const palette = purposeStyle(a.purpose);
const purposeLabel = treatmentTypeLabelFromCatalog(a.purpose, treatmentCatalog); const purposeLabel = treatmentTypeLabelFromCatalog(a.purpose, treatmentCatalog);
const purposeIndex = treatmentCatalog.findIndex((e) => e.code === a.purpose);
return ( return (
<Card <Card
as="button" as="button"
@@ -100,10 +102,10 @@ export function AppointmentsStrip({
type="button" type="button"
onClick={() => onSelectAppointment(a.id)} onClick={() => onSelectAppointment(a.id)}
padding="none" padding="none"
style={treatmentTypeBannerStyle(a.purpose, purposeIndex < 0 ? 0 : purposeIndex)}
className={` className={`
text-left rounded-[var(--radius-sm)] px-3 py-2 min-w-[200px] max-w-[280px] transition-shadow min-h-[52px] 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 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'} ${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'}
`} `}
> >

View File

@@ -1,7 +1,9 @@
'use client'; 'use client';
import { purposeStyle } from '@/components/ui/appointments/appointmentPurposeStyles'; import {
import { formatCodeAsLabel } from '@/components/ui/treatment/treatmentTypeDisplay'; formatCodeAsLabel,
treatmentTypeBannerStyle,
} from '@/components/ui/treatment/treatmentTypeDisplay';
interface TreatmentTypeBadgeProps { interface TreatmentTypeBadgeProps {
type: string; type: string;
@@ -14,7 +16,8 @@ export function TreatmentTypeBadge({ type, label, className = '' }: TreatmentTyp
return ( return (
<span <span
className={`inline-flex items-center justify-center box-border rounded-md border min-h-[1.75rem] px-2.5 py-1 text-xs font-medium leading-none shrink-0 ${purposeStyle(type)} ${className}`.trim()} className={`inline-flex items-center justify-center box-border rounded-md border min-h-[1.75rem] px-2.5 py-1 text-xs font-medium leading-none shrink-0 ${className}`.trim()}
style={treatmentTypeBannerStyle(type)}
> >
{display} {display}
</span> </span>

View File

@@ -242,6 +242,10 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
const [orgs, setOrgs] = useState<LinkedOrganizationOption[]>([]); const [orgs, setOrgs] = useState<LinkedOrganizationOption[]>([]);
const [labDependentCodes, setLabDependentCodes] = useState<Set<string>>(new Set()); const [labDependentCodes, setLabDependentCodes] = useState<Set<string>>(new Set());
const [treatmentCatalog, setTreatmentCatalog] = useState<TreatmentCatalogEntry[]>([]); const [treatmentCatalog, setTreatmentCatalog] = useState<TreatmentCatalogEntry[]>([]);
const treatmentDropdownCatalog = useMemo(
() => treatmentCatalog.filter((entry) => entry.availableInTreatment),
[treatmentCatalog],
);
const [details, setDetails] = useState<TreatmentDetailDraft[]>(() => [newDetail()]); const [details, setDetails] = useState<TreatmentDetailDraft[]>(() => [newDetail()]);
const [labCaseDrafts, setLabCaseDrafts] = useState<LabCaseDraft[]>([]); const [labCaseDrafts, setLabCaseDrafts] = useState<LabCaseDraft[]>([]);
@@ -984,7 +988,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
onDetailsChange={setDetails} onDetailsChange={setDetails}
isDetailLocked={isDetailLocked} isDetailLocked={isDetailLocked}
labDependentCodes={labDependentCodes} labDependentCodes={labDependentCodes}
treatmentCatalog={treatmentCatalog} treatmentCatalog={treatmentDropdownCatalog}
disabled={!canEditTreatmentForDay} disabled={!canEditTreatmentForDay}
canEdit={canEdit} canEdit={canEdit}
saveStatus={saveStatus} saveStatus={saveStatus}

View File

@@ -1,9 +1,19 @@
import type { CSSProperties } from 'react';
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; 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<string, string> = { const TREATMENT_TYPE_COLORS: Record<string, string> = {
restoration: '#fed7aa', restoration: '#fed7aa',
specialized_restoration: '#fdba74', specialized_restoration: '#fdba74',
radiography: '#e2e8f0', radiography: '#cbd5e1',
endo: '#fecaca', endo: '#fecaca',
surgery: '#fca5a5', surgery: '#fca5a5',
prosthesis: '#c4b5fd', prosthesis: '#c4b5fd',
@@ -11,16 +21,43 @@ const TREATMENT_TYPE_COLORS: Record<string, string> = {
orthodontics: '#93c5fd', orthodontics: '#93c5fd',
perio: '#86efac', perio: '#86efac',
pediatrics: '#fde68a', pediatrics: '#fde68a',
extraction: '#f87171', extraction: '#f9a8d4',
clinic_visit: '#bae6fd', 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 <select> dropdowns. */
export const DROPDOWN_OPTION_BG = '#14253d';
export function treatmentTypeColor(code: string, index = 0): string { export function treatmentTypeColor(code: string, index = 0): string {
return TREATMENT_TYPE_COLORS[code] ?? FALLBACK_COLORS[index % FALLBACK_COLORS.length]; return TREATMENT_TYPE_COLORS[code] ?? FALLBACK_COLORS[index % FALLBACK_COLORS.length];
} }
/** Filled swatch (legend dots, small indicators). */
export function treatmentTypeSwatchStyle(code: string, index = 0): CSSProperties {
const color = treatmentTypeColor(code, index);
return { backgroundColor: color, borderColor: 'rgba(0, 0, 0, 0.18)' };
}
/** Colored banner / card fill with readable dark text (schedule blocks, appointment cards). */
export function treatmentTypeBannerStyle(code: string, index = 0): CSSProperties {
const color = treatmentTypeColor(code, index);
return {
backgroundColor: color,
borderColor: 'rgba(0, 0, 0, 0.16)',
color: BANNER_INK,
};
}
/** Pastel option text on the dark dropdown background. */
export function treatmentTypeOptionStyle(code: string, index = 0): CSSProperties {
return { color: treatmentTypeColor(code, index), backgroundColor: DROPDOWN_OPTION_BG };
}
export function treatmentTypeLabelFromCatalog( export function treatmentTypeLabelFromCatalog(
code: string, code: string,
catalog: TreatmentCatalogEntry[], catalog: TreatmentCatalogEntry[],

View File

@@ -1,9 +1,15 @@
import { apiClient } from './client'; import { apiClient } from './client';
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
export type TreatmentCatalogContext = 'appointment' | 'treatment';
export const treatmentCatalogApi = { export const treatmentCatalogApi = {
list: async (): Promise<{ success: boolean; data: TreatmentCatalogEntry[] }> => { list: async (
const response = await apiClient.get('/treatment-catalog'); context?: TreatmentCatalogContext,
): Promise<{ success: boolean; data: TreatmentCatalogEntry[] }> => {
const response = await apiClient.get('/treatment-catalog', {
params: context ? { context } : undefined,
});
return response.data; return response.data;
}, },
}; };

View File

@@ -1,14 +1,11 @@
import type { Patient } from './patient'; import type { Patient } from './patient';
export const APPOINTMENT_PURPOSES = [ /**
'consultation', * An appointment purpose is any treatment-type code selectable in the appointment
'filling', * context (see the treatment catalog with `?context=appointment`). Kept as a
'endo', * string so the set is driven by the DB catalog rather than a hardcoded union.
'visit', */
'hygiene', export type AppointmentPurpose = string;
] as const;
export type AppointmentPurpose = (typeof APPOINTMENT_PURPOSES)[number];
export interface AppointmentColumnProvider { export interface AppointmentColumnProvider {
userId: string; userId: string;

View File

@@ -4,6 +4,8 @@ export interface TreatmentCatalogEntry {
labDependent: boolean; labDependent: boolean;
sortOrder: number; sortOrder: number;
label: string; label: string;
availableInAppointments: boolean;
availableInTreatment: boolean;
} }
export interface ProsthesisCatalogEntry { export interface ProsthesisCatalogEntry {