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

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

View File

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

View File

@@ -18,10 +18,11 @@ import {
findOverlapCluster,
lanePositionStyles,
} from '@/components/appointments/appointmentOverlapLayout';
import { purposeStyle } from '@/components/ui/appointments/appointmentPurposeStyles';
import { purposeBannerStyle } from '@/components/ui/appointments/appointmentPurposeStyles';
import { AppointmentOverlapPopover } from '@/components/ui/appointments/AppointmentOverlapPopover';
import { formatMobileForDisplay } from '@/lib/phone';
import { startOfLocalDay } from '@/components/appointments/appointmentTime';
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
const HOUR_PX = 80;
const SLOT_PX = (HOUR_PX * SCHEDULE_SLOT_MINUTES) / 60;
@@ -78,6 +79,7 @@ interface AppointmentScheduleGridProps {
day: Date;
providers: AppointmentColumnProvider[];
appointments: AppointmentRecord[];
treatmentCatalog: TreatmentCatalogEntry[];
canBook: boolean;
onSlotClick: (startMinute: number, providerUserId: string, providerName: string) => void;
onAppointmentClick?: (appointment: AppointmentRecord) => void;
@@ -88,6 +90,7 @@ export function AppointmentScheduleGrid({
day,
providers,
appointments,
treatmentCatalog,
canBook,
onSlotClick,
onAppointmentClick,
@@ -320,7 +323,7 @@ export function AppointmentScheduleGrid({
e.currentTarget,
)
}
className={`absolute min-h-0 overflow-hidden rounded-[var(--radius-sm)] border pointer-events-auto z-10 flex text-left ${purposeStyle(apt.purpose)} focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/35 ${
className={`absolute min-h-0 overflow-hidden rounded-[var(--radius-sm)] border pointer-events-auto z-10 flex text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/35 ${
outsideHours ? 'opacity-70 ring-1 ring-amber-500/60' : ''
} ${
isUnderOneHour
@@ -332,6 +335,7 @@ export function AppointmentScheduleGrid({
height: pos.height,
left: lanePos.left,
width: lanePos.width,
...purposeBannerStyle(apt.purpose, treatmentCatalog),
}}
title={bannerTitle}
>
@@ -366,6 +370,7 @@ export function AppointmentScheduleGrid({
{overlapPopover && (
<AppointmentOverlapPopover
appointments={overlapPopover.appointments}
treatmentCatalog={treatmentCatalog}
anchorRect={overlapPopover.anchorRect}
onSelect={(apt) => {
const provider = providers.find((p) => p.userId === apt.providerUserId);

View File

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

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