improvement: all clinic side ui components related to treatment types updated based on the new real world data.
This commit is contained in:
@@ -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' },
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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.');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
};
|
}
|
||||||
|
|||||||
@@ -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'}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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[],
|
||||||
|
|||||||
@@ -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;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user