Merge branch 'master' into feature/tab-warning-flag
This commit is contained in:
27
frontend/src/components/i18n/LocaleSync.tsx
Normal file
27
frontend/src/components/i18n/LocaleSync.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useLocale } from 'next-intl';
|
||||
import { useAuth } from '@/lib/hooks/useAuth';
|
||||
import { usePathname, useRouter } from '@/i18n/navigation';
|
||||
import type { AppLocale } from '@/i18n/routing';
|
||||
import { isAppLocale } from '@/i18n/routing';
|
||||
|
||||
/** Redirect authenticated users to their saved profile language when it differs from the URL. */
|
||||
export function LocaleSync() {
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { user, isAuthReady } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthReady || !user?.language) return;
|
||||
|
||||
const preferred = user.language;
|
||||
if (!isAppLocale(preferred) || preferred === locale) return;
|
||||
|
||||
router.replace(pathname, { locale: preferred as AppLocale });
|
||||
}, [isAuthReady, user?.language, locale, pathname, router]);
|
||||
|
||||
return null;
|
||||
}
|
||||
103
frontend/src/components/staff/StaffWorkingHoursStep.tsx
Normal file
103
frontend/src/components/staff/StaffWorkingHoursStep.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { WorkingHoursEditor } from '@/components/staff/WorkingHoursEditor';
|
||||
import {
|
||||
blocksFromEditorDays,
|
||||
editorDaysFromBlocks,
|
||||
emptyWorkingHoursEditorDays,
|
||||
validateEditorDays,
|
||||
type WorkingHoursEditorDay,
|
||||
} from '@/components/staff/workingHours';
|
||||
|
||||
interface StaffWorkingHoursStepProps {
|
||||
days: WorkingHoursEditorDay[];
|
||||
autoRepeatWeekly: boolean;
|
||||
onDaysChange: (days: WorkingHoursEditorDay[]) => void;
|
||||
onAutoRepeatWeeklyChange: (value: boolean) => void;
|
||||
onValidationChange?: (error: string | null) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function StaffWorkingHoursStep({
|
||||
days,
|
||||
autoRepeatWeekly,
|
||||
onDaysChange,
|
||||
onAutoRepeatWeeklyChange,
|
||||
onValidationChange,
|
||||
disabled,
|
||||
}: StaffWorkingHoursStepProps) {
|
||||
const t = useTranslations('staff.workingHours');
|
||||
|
||||
useEffect(() => {
|
||||
onValidationChange?.(validateEditorDays(days, t));
|
||||
}, [days, onValidationChange, t]);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-[var(--radius-md)] border border-primary/25 bg-primary/5 px-3 py-3">
|
||||
<p className="text-sm text-text-primary font-medium">{t('recommendedTitle')}</p>
|
||||
<p className="text-sm text-text-secondary mt-1">{t('recommendedBody')}</p>
|
||||
</div>
|
||||
<WorkingHoursEditor
|
||||
days={days}
|
||||
autoRepeatWeekly={autoRepeatWeekly}
|
||||
onDaysChange={onDaysChange}
|
||||
onAutoRepeatWeeklyChange={onAutoRepeatWeeklyChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function createDefaultWorkingHoursState() {
|
||||
return {
|
||||
days: emptyWorkingHoursEditorDays(),
|
||||
autoRepeatWeekly: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function workingHoursPayloadFromState(state: {
|
||||
days: WorkingHoursEditorDay[];
|
||||
autoRepeatWeekly: boolean;
|
||||
}) {
|
||||
return {
|
||||
autoRepeatWeekly: state.autoRepeatWeekly,
|
||||
blocks: blocksFromEditorDays(state.days),
|
||||
};
|
||||
}
|
||||
|
||||
export function workingHoursStateFromApi(data: {
|
||||
autoRepeatWeekly: boolean;
|
||||
blocks: { dayOfWeek: number; startMinute: number; endMinute: number; sortOrder?: number }[];
|
||||
}) {
|
||||
const hasBlocks = data.blocks.length > 0;
|
||||
return {
|
||||
days: hasBlocks ? editorDaysFromBlocks(data.blocks) : emptyWorkingHoursEditorDays(),
|
||||
autoRepeatWeekly: data.autoRepeatWeekly,
|
||||
};
|
||||
}
|
||||
|
||||
export function useWorkingHoursForm(initial?: {
|
||||
days: WorkingHoursEditorDay[];
|
||||
autoRepeatWeekly: boolean;
|
||||
}) {
|
||||
const [days, setDays] = useState(initial?.days ?? emptyWorkingHoursEditorDays());
|
||||
const [autoRepeatWeekly, setAutoRepeatWeekly] = useState(initial?.autoRepeatWeekly ?? true);
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
|
||||
return {
|
||||
days,
|
||||
setDays,
|
||||
autoRepeatWeekly,
|
||||
setAutoRepeatWeekly,
|
||||
validationError,
|
||||
setValidationError,
|
||||
reset(next?: { days: WorkingHoursEditorDay[]; autoRepeatWeekly: boolean }) {
|
||||
setDays(next?.days ?? emptyWorkingHoursEditorDays());
|
||||
setAutoRepeatWeekly(next?.autoRepeatWeekly ?? true);
|
||||
setValidationError(null);
|
||||
},
|
||||
};
|
||||
}
|
||||
171
frontend/src/components/staff/WorkingHoursEditor.tsx
Normal file
171
frontend/src/components/staff/WorkingHoursEditor.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
'use client';
|
||||
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Button } from '@/components/ui/shared/Button';
|
||||
import { Checkbox } from '@/components/ui/shared/Checkbox';
|
||||
import {
|
||||
MINUTES_PER_DAY,
|
||||
WEEKDAY_KEYS,
|
||||
minutesToTimeInput,
|
||||
timeInputToMinutes,
|
||||
type WorkingHoursEditorDay,
|
||||
} from '@/components/staff/workingHours';
|
||||
|
||||
interface WorkingHoursEditorProps {
|
||||
days: WorkingHoursEditorDay[];
|
||||
autoRepeatWeekly: boolean;
|
||||
onDaysChange: (days: WorkingHoursEditorDay[]) => void;
|
||||
onAutoRepeatWeeklyChange: (value: boolean) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const timeInputClass =
|
||||
'w-full rounded-[var(--radius-md)] border border-border bg-background-secondary/90 text-text-primary px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-primary/35';
|
||||
|
||||
export function WorkingHoursEditor({
|
||||
days,
|
||||
autoRepeatWeekly,
|
||||
onDaysChange,
|
||||
onAutoRepeatWeeklyChange,
|
||||
disabled = false,
|
||||
}: WorkingHoursEditorProps) {
|
||||
const t = useTranslations('staff.workingHours');
|
||||
|
||||
function updateDay(dayOfWeek: number, patch: Partial<WorkingHoursEditorDay>) {
|
||||
onDaysChange(
|
||||
days.map((day) => (day.dayOfWeek === dayOfWeek ? { ...day, ...patch } : day)),
|
||||
);
|
||||
}
|
||||
|
||||
function updateShift(
|
||||
dayOfWeek: number,
|
||||
shiftIndex: number,
|
||||
field: 'startMinute' | 'endMinute',
|
||||
value: string,
|
||||
) {
|
||||
const minutes = timeInputToMinutes(value);
|
||||
if (minutes == null) return;
|
||||
const day = days.find((d) => d.dayOfWeek === dayOfWeek);
|
||||
if (!day) return;
|
||||
const shifts = day.shifts.map((shift, index) =>
|
||||
index === shiftIndex ? { ...shift, [field]: minutes } : shift,
|
||||
);
|
||||
updateDay(dayOfWeek, { shifts });
|
||||
}
|
||||
|
||||
function addShift(dayOfWeek: number) {
|
||||
const day = days.find((d) => d.dayOfWeek === dayOfWeek);
|
||||
if (!day) return;
|
||||
const last = day.shifts[day.shifts.length - 1];
|
||||
const startMinute = last ? Math.min(last.endMinute + 60, MINUTES_PER_DAY - 60) : 9 * 60;
|
||||
updateDay(dayOfWeek, {
|
||||
shifts: [...day.shifts, { startMinute, endMinute: Math.min(startMinute + 120, MINUTES_PER_DAY) }],
|
||||
});
|
||||
}
|
||||
|
||||
function removeShift(dayOfWeek: number, shiftIndex: number) {
|
||||
const day = days.find((d) => d.dayOfWeek === dayOfWeek);
|
||||
if (!day || day.shifts.length <= 1) return;
|
||||
updateDay(dayOfWeek, {
|
||||
shifts: day.shifts.filter((_, index) => index !== shiftIndex),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-text-secondary">{t('intro')}</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
{days.map((day) => (
|
||||
<div
|
||||
key={day.dayOfWeek}
|
||||
className="rounded-[var(--radius-md)] border border-border/60 bg-background-card/40 px-3 py-3 space-y-3"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-sm font-medium text-text-primary w-10">
|
||||
{t(WEEKDAY_KEYS[day.dayOfWeek])}
|
||||
</span>
|
||||
<Checkbox
|
||||
checked={day.isWorking}
|
||||
disabled={disabled}
|
||||
label={t('workingDay')}
|
||||
onChange={(checked) => {
|
||||
updateDay(day.dayOfWeek, {
|
||||
isWorking: checked,
|
||||
shifts: checked
|
||||
? day.shifts.length > 0
|
||||
? day.shifts
|
||||
: [{ startMinute: 9 * 60, endMinute: 17 * 60 }]
|
||||
: day.shifts,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{day.isWorking && (
|
||||
<div className="space-y-2 pl-0 sm:pl-10">
|
||||
{day.shifts.map((shift, shiftIndex) => (
|
||||
<div key={shiftIndex} className="flex items-end gap-2">
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs text-text-muted mb-1">{t('start')}</label>
|
||||
<input
|
||||
type="time"
|
||||
step={300}
|
||||
disabled={disabled}
|
||||
className={timeInputClass}
|
||||
value={minutesToTimeInput(shift.startMinute)}
|
||||
onChange={(e) =>
|
||||
updateShift(day.dayOfWeek, shiftIndex, 'startMinute', e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs text-text-muted mb-1">{t('end')}</label>
|
||||
<input
|
||||
type="time"
|
||||
step={300}
|
||||
disabled={disabled}
|
||||
className={timeInputClass}
|
||||
value={minutesToTimeInput(shift.endMinute)}
|
||||
onChange={(e) =>
|
||||
updateShift(day.dayOfWeek, shiftIndex, 'endMinute', e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || day.shifts.length <= 1}
|
||||
className="p-2 rounded-md text-text-muted hover:text-red-500 hover:bg-red-500/10 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
aria-label={t('removeShift')}
|
||||
onClick={() => removeShift(day.dayOfWeek, shiftIndex)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
onClick={() => addShift(day.dayOfWeek)}
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5 mr-1" />
|
||||
{t('addShift')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Checkbox
|
||||
checked={autoRepeatWeekly}
|
||||
disabled={disabled}
|
||||
label={t('autoRepeatWeekly')}
|
||||
onChange={onAutoRepeatWeeklyChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,27 +4,30 @@
|
||||
*/
|
||||
|
||||
export const STAFF_FEATURE_GROUPS = [
|
||||
{ label: 'Today', read: 'TAB_TODAY_READ', edit: 'TAB_TODAY_EDIT' },
|
||||
{ label: 'Staff', read: 'TAB_STAFF_READ', edit: 'TAB_STAFF_EDIT' },
|
||||
{ label: 'Organizations', read: 'TAB_ORGANIZATIONS_READ', edit: 'TAB_ORGANIZATIONS_EDIT' },
|
||||
{ label: 'Patients', read: 'TAB_PATIENTS_READ', edit: 'TAB_PATIENTS_EDIT' },
|
||||
{ label: 'Appointment', read: 'TAB_APPOINTMENTS_READ', edit: 'TAB_APPOINTMENTS_EDIT' },
|
||||
{ label: 'Treatment', read: 'TAB_TREATMENT_READ', edit: 'TAB_TREATMENT_EDIT' },
|
||||
{ label: 'Billing', read: 'TAB_BILLING_READ', edit: 'TAB_BILLING_EDIT' },
|
||||
{ label: 'Reports', read: 'TAB_REPORTS_READ', edit: 'TAB_REPORTS_EDIT' },
|
||||
{ labelKey: 'featureToday', read: 'TAB_TODAY_READ', edit: 'TAB_TODAY_EDIT' },
|
||||
{ labelKey: 'featureStaff', read: 'TAB_STAFF_READ', edit: 'TAB_STAFF_EDIT' },
|
||||
{ labelKey: 'featureOrganizations', read: 'TAB_ORGANIZATIONS_READ', edit: 'TAB_ORGANIZATIONS_EDIT' },
|
||||
{ labelKey: 'featurePatients', read: 'TAB_PATIENTS_READ', edit: 'TAB_PATIENTS_EDIT' },
|
||||
{ labelKey: 'featureAppointment', read: 'TAB_APPOINTMENTS_READ', edit: 'TAB_APPOINTMENTS_EDIT' },
|
||||
{ labelKey: 'featureTreatment', read: 'TAB_TREATMENT_READ', edit: 'TAB_TREATMENT_EDIT' },
|
||||
{ labelKey: 'featureBilling', read: 'TAB_BILLING_READ', edit: 'TAB_BILLING_EDIT' },
|
||||
{ labelKey: 'featureReports', read: 'TAB_REPORTS_READ', edit: 'TAB_REPORTS_EDIT' },
|
||||
] as const;
|
||||
|
||||
export type FeaturePermState = Record<string, { read: boolean; edit: boolean }>;
|
||||
export type OrgType = 'CLINIC' | 'LAB' | null | undefined;
|
||||
|
||||
type StaffFeaturesTranslate = (key: string) => string;
|
||||
|
||||
export function resolveStaffFeatureLabel(
|
||||
group: (typeof STAFF_FEATURE_GROUPS)[number],
|
||||
organizationType: OrgType,
|
||||
t: StaffFeaturesTranslate,
|
||||
): string {
|
||||
if (group.read === 'TAB_ORGANIZATIONS_READ') {
|
||||
return organizationType === 'LAB' ? 'Clinics' : 'Labs';
|
||||
return organizationType === 'LAB' ? t('featureClinics') : t('featureLabs');
|
||||
}
|
||||
return group.label;
|
||||
return t(group.labelKey);
|
||||
}
|
||||
|
||||
export function emptyFeaturePermissionState(): FeaturePermState {
|
||||
@@ -57,20 +60,25 @@ export function permissionNamesFromFeatureState(state: FeaturePermState): string
|
||||
return out;
|
||||
}
|
||||
|
||||
export function featureStateHasTreatmentEdit(state: FeaturePermState): boolean {
|
||||
return Boolean(state.TAB_TREATMENT_EDIT?.edit);
|
||||
}
|
||||
|
||||
/** Human-readable access for the team table — feature name, or "Feature (Read only)" */
|
||||
export function formatAccessSummary(
|
||||
permissionNames: string[] | null | undefined,
|
||||
organizationType?: OrgType,
|
||||
organizationType: OrgType,
|
||||
t: StaffFeaturesTranslate,
|
||||
): string {
|
||||
if (!permissionNames?.length) return 'No tab access';
|
||||
if (!permissionNames?.length) return t('noTabAccess');
|
||||
const set = new Set(permissionNames);
|
||||
const parts: string[] = [];
|
||||
for (const g of STAFF_FEATURE_GROUPS) {
|
||||
const hasEdit = set.has(g.edit);
|
||||
const hasRead = set.has(g.read) || hasEdit;
|
||||
if (!hasRead) continue;
|
||||
const label = resolveStaffFeatureLabel(g, organizationType);
|
||||
parts.push(hasEdit ? label : `${label} (Read only)`);
|
||||
const label = resolveStaffFeatureLabel(g, organizationType, t);
|
||||
parts.push(hasEdit ? label : `${label} ${t('readOnlySuffix')}`);
|
||||
}
|
||||
return parts.length ? parts.join(' · ') : 'No tab access';
|
||||
return parts.length ? parts.join(' · ') : t('noTabAccess');
|
||||
}
|
||||
|
||||
227
frontend/src/components/staff/workingHours.ts
Normal file
227
frontend/src/components/staff/workingHours.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
export const MINUTES_PER_DAY = 24 * 60;
|
||||
export const SCHEDULE_SLOT_MINUTES = 15;
|
||||
|
||||
export const WEEKDAY_KEYS = [
|
||||
'weekdayMon',
|
||||
'weekdayTue',
|
||||
'weekdayWed',
|
||||
'weekdayThu',
|
||||
'weekdayFri',
|
||||
'weekdaySat',
|
||||
'weekdaySun',
|
||||
] as const;
|
||||
|
||||
type WorkingHoursTranslate = (key: string, values?: { day: string }) => string;
|
||||
|
||||
export type WorkingHoursBlock = {
|
||||
dayOfWeek: number;
|
||||
startMinute: number;
|
||||
endMinute: number;
|
||||
sortOrder?: number;
|
||||
};
|
||||
|
||||
export type WorkingHoursDayBlock = {
|
||||
startMinute: number;
|
||||
endMinute: number;
|
||||
};
|
||||
|
||||
export type WorkingHoursEditorDay = {
|
||||
dayOfWeek: number;
|
||||
isWorking: boolean;
|
||||
shifts: { startMinute: number; endMinute: number }[];
|
||||
};
|
||||
|
||||
export function localDayOfWeekMondayZero(dayOfWeekJs: number): number {
|
||||
return dayOfWeekJs === 0 ? 6 : dayOfWeekJs - 1;
|
||||
}
|
||||
|
||||
export function minutesToTimeInput(minutes: number): string {
|
||||
const h = Math.floor(minutes / 60);
|
||||
const m = minutes % 60;
|
||||
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function timeInputToMinutes(value: string): number | null {
|
||||
const match = /^(\d{1,2}):(\d{2})$/.exec(value.trim());
|
||||
if (!match) return null;
|
||||
const h = Number(match[1]);
|
||||
const m = Number(match[2]);
|
||||
if (h < 0 || h > 23 || m < 0 || m > 59) return null;
|
||||
return h * 60 + m;
|
||||
}
|
||||
|
||||
export function formatMinuteLabel(minute: number): string {
|
||||
const d = new Date(2000, 0, 1, Math.floor(minute / 60), minute % 60, 0, 0);
|
||||
return d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit', hour12: true });
|
||||
}
|
||||
|
||||
export function emptyWorkingHoursEditorDays(): WorkingHoursEditorDay[] {
|
||||
return WEEKDAY_KEYS.map((_, dayOfWeek) => ({
|
||||
dayOfWeek,
|
||||
isWorking: false,
|
||||
shifts: [{ startMinute: 9 * 60, endMinute: 17 * 60 }],
|
||||
}));
|
||||
}
|
||||
|
||||
export function editorDaysFromBlocks(blocks: WorkingHoursBlock[]): WorkingHoursEditorDay[] {
|
||||
const byDay = new Map<number, WorkingHoursDayBlock[]>();
|
||||
for (const block of blocks) {
|
||||
const list = byDay.get(block.dayOfWeek) ?? [];
|
||||
list.push({ startMinute: block.startMinute, endMinute: block.endMinute });
|
||||
byDay.set(block.dayOfWeek, list);
|
||||
}
|
||||
|
||||
return WEEKDAY_KEYS.map((_, dayOfWeek) => {
|
||||
const shifts = (byDay.get(dayOfWeek) ?? []).sort(
|
||||
(a, b) => a.startMinute - b.startMinute || a.endMinute - b.endMinute,
|
||||
);
|
||||
return {
|
||||
dayOfWeek,
|
||||
isWorking: shifts.length > 0,
|
||||
shifts: shifts.length > 0 ? shifts : [{ startMinute: 9 * 60, endMinute: 17 * 60 }],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function blocksFromEditorDays(days: WorkingHoursEditorDay[]): WorkingHoursBlock[] {
|
||||
const blocks: WorkingHoursBlock[] = [];
|
||||
for (const day of days) {
|
||||
if (!day.isWorking) continue;
|
||||
day.shifts.forEach((shift, index) => {
|
||||
blocks.push({
|
||||
dayOfWeek: day.dayOfWeek,
|
||||
startMinute: shift.startMinute,
|
||||
endMinute: shift.endMinute,
|
||||
sortOrder: index,
|
||||
});
|
||||
});
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
export function validateEditorDays(
|
||||
days: WorkingHoursEditorDay[],
|
||||
t: WorkingHoursTranslate,
|
||||
): string | null {
|
||||
for (const day of days) {
|
||||
if (!day.isWorking) continue;
|
||||
const dayLabel = t(WEEKDAY_KEYS[day.dayOfWeek]);
|
||||
if (day.shifts.length === 0) {
|
||||
return t('validationNeedsShift', { day: dayLabel });
|
||||
}
|
||||
const sorted = [...day.shifts].sort((a, b) => a.startMinute - b.startMinute);
|
||||
for (const shift of sorted) {
|
||||
if (shift.endMinute <= shift.startMinute) {
|
||||
return t('validationEndAfterStart', { day: dayLabel });
|
||||
}
|
||||
}
|
||||
for (let i = 1; i < sorted.length; i += 1) {
|
||||
if (sorted[i].startMinute < sorted[i - 1].endMinute) {
|
||||
return t('validationOverlap', { day: dayLabel });
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function blocksForDay(blocks: WorkingHoursBlock[], dayOfWeek: number): WorkingHoursDayBlock[] {
|
||||
return blocks
|
||||
.filter((b) => b.dayOfWeek === dayOfWeek)
|
||||
.sort((a, b) => a.startMinute - b.startMinute || a.endMinute - b.endMinute)
|
||||
.map((b) => ({ startMinute: b.startMinute, endMinute: b.endMinute }));
|
||||
}
|
||||
|
||||
export function isMinuteWithinWorkingBlocks(
|
||||
minute: number,
|
||||
dayBlocks: WorkingHoursDayBlock[],
|
||||
): boolean {
|
||||
return dayBlocks.some((b) => minute >= b.startMinute && minute < b.endMinute);
|
||||
}
|
||||
|
||||
export function isSlotWithinWorkingBlocks(
|
||||
slotStartMinute: number,
|
||||
slotMinutes: number,
|
||||
dayBlocks: WorkingHoursDayBlock[],
|
||||
): boolean {
|
||||
const slotEnd = slotStartMinute + slotMinutes;
|
||||
for (let m = slotStartMinute; m < slotEnd; m += 1) {
|
||||
if (!isMinuteWithinWorkingBlocks(m, dayBlocks)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function appointmentWithinWorkingHours(
|
||||
startAt: Date,
|
||||
endAt: Date,
|
||||
dayBlocks: WorkingHoursDayBlock[],
|
||||
): boolean {
|
||||
const startMinute = startAt.getHours() * 60 + startAt.getMinutes();
|
||||
const endMinute = endAt.getHours() * 60 + endAt.getMinutes();
|
||||
if (endMinute <= startMinute) return false;
|
||||
for (let m = startMinute; m < endMinute; m += 1) {
|
||||
if (!isMinuteWithinWorkingBlocks(m, dayBlocks)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function unionDayBlockRange(dayBlocksList: WorkingHoursDayBlock[][]): {
|
||||
startMinute: number;
|
||||
endMinute: number;
|
||||
} | null {
|
||||
let startMinute: number | null = null;
|
||||
let endMinute: number | null = null;
|
||||
|
||||
for (const dayBlocks of dayBlocksList) {
|
||||
for (const block of dayBlocks) {
|
||||
startMinute =
|
||||
startMinute == null ? block.startMinute : Math.min(startMinute, block.startMinute);
|
||||
endMinute = endMinute == null ? block.endMinute : Math.max(endMinute, block.endMinute);
|
||||
}
|
||||
}
|
||||
|
||||
if (startMinute == null || endMinute == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { startMinute, endMinute };
|
||||
}
|
||||
|
||||
export function snapRangeToSlots(
|
||||
startMinute: number,
|
||||
endMinute: number,
|
||||
slotMinutes: number,
|
||||
): { startMinute: number; endMinute: number; slotCount: number } {
|
||||
const start = Math.floor(startMinute / slotMinutes) * slotMinutes;
|
||||
const end = Math.ceil(endMinute / slotMinutes) * slotMinutes;
|
||||
return {
|
||||
startMinute: start,
|
||||
endMinute: end,
|
||||
slotCount: Math.max(1, (end - start) / slotMinutes),
|
||||
};
|
||||
}
|
||||
|
||||
export function generateSlotStarts(
|
||||
startMinute: number,
|
||||
endMinute: number,
|
||||
slotMinutes: number,
|
||||
): number[] {
|
||||
const slots: number[] = [];
|
||||
for (let m = startMinute; m < endMinute; m += slotMinutes) {
|
||||
slots.push(m);
|
||||
}
|
||||
return slots;
|
||||
}
|
||||
|
||||
export function generateHourLabelsInRange(startMinute: number, endMinute: number): number[] {
|
||||
const firstHour = Math.floor(startMinute / 60);
|
||||
const lastHour = Math.ceil(endMinute / 60);
|
||||
const hours: number[] = [];
|
||||
for (let h = firstHour; h < lastHour; h += 1) {
|
||||
hours.push(h);
|
||||
}
|
||||
return hours;
|
||||
}
|
||||
@@ -1,17 +1,23 @@
|
||||
import type { LinkedOrganizationOption, TreatmentCaseSendInfo } from '@/types/treatment';
|
||||
|
||||
export type CaseSendLabelT = (
|
||||
key: 'sentToAt' | 'fallbackOrgName',
|
||||
values?: { orgName: string; datetime: string },
|
||||
) => string;
|
||||
|
||||
export function formatCaseSentLines(
|
||||
sends: TreatmentCaseSendInfo[] | undefined,
|
||||
fallback?: {
|
||||
fallback: {
|
||||
organizationIds: string[];
|
||||
sentAt: string | null;
|
||||
orgs?: LinkedOrganizationOption[];
|
||||
},
|
||||
} | undefined,
|
||||
t: CaseSendLabelT,
|
||||
): string[] {
|
||||
if (sends?.length) {
|
||||
return sends.map((s) => {
|
||||
const at = new Date(s.sentAt).toLocaleString();
|
||||
return `Sent to ${s.organizationName} at ${at}`;
|
||||
return t('sentToAt', { orgName: s.organizationName, datetime: at });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -19,8 +25,8 @@ export function formatCaseSentLines(
|
||||
const at = new Date(fallback.sentAt).toLocaleString();
|
||||
const nameById = new Map(fallback.orgs?.map((o) => [o.id, o.name]) ?? []);
|
||||
return fallback.organizationIds.map((id) => {
|
||||
const name = nameById.get(id) ?? 'organization';
|
||||
return `Sent to ${name} at ${at}`;
|
||||
const name = nameById.get(id) ?? t('fallbackOrgName');
|
||||
return t('sentToAt', { orgName: name, datetime: at });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -29,12 +35,13 @@ export function formatCaseSentLines(
|
||||
|
||||
export function formatCaseSentSummary(
|
||||
sends: TreatmentCaseSendInfo[] | undefined,
|
||||
fallback?: {
|
||||
fallback: {
|
||||
organizationIds: string[];
|
||||
sentAt: string | null;
|
||||
orgs?: LinkedOrganizationOption[];
|
||||
},
|
||||
} | undefined,
|
||||
t: CaseSendLabelT,
|
||||
): string | null {
|
||||
const lines = formatCaseSentLines(sends, fallback);
|
||||
const lines = formatCaseSentLines(sends, fallback, t);
|
||||
return lines.length > 0 ? lines.join(' · ') : null;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
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_PURPOSE_LABEL } from '@/components/ui/appointments/appointmentPurposeStyles';
|
||||
import { APPOINTMENT_PURPOSES } from '@/types/appointment';
|
||||
import { getPurposeLabel } from '@/components/ui/appointments/appointmentPurposeStyles';
|
||||
import type { Patient } from '@/types/patient';
|
||||
import {
|
||||
combineLocalDateAndTime,
|
||||
@@ -20,7 +22,7 @@ interface AppointmentBookingModalProps {
|
||||
patient: Patient | undefined;
|
||||
providerUserId: string | null;
|
||||
providerName: string;
|
||||
initialHour: number;
|
||||
initialStartMinute: number;
|
||||
onClose: () => void;
|
||||
onSubmit: (payload: {
|
||||
patientId: string;
|
||||
@@ -36,13 +38,21 @@ 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,
|
||||
patient,
|
||||
providerUserId,
|
||||
providerName,
|
||||
initialHour,
|
||||
initialStartMinute,
|
||||
onClose,
|
||||
onSubmit,
|
||||
editingAppointment = null,
|
||||
@@ -51,20 +61,15 @@ export function AppointmentBookingModal({
|
||||
onDelete,
|
||||
deleting = false,
|
||||
}: AppointmentBookingModalProps) {
|
||||
const t = useTranslations('appointments');
|
||||
const tCommon = useTranslations('common');
|
||||
const tPatients = useTranslations('patients');
|
||||
|
||||
const [startTime, setStartTime] = useState('09:00');
|
||||
const [endTime, setEndTime] = useState('10:00');
|
||||
const [purpose, setPurpose] = useState<AppointmentPurpose>('consultation');
|
||||
const [error, setError] = useState('');
|
||||
const purposeTextColor =
|
||||
purpose === 'consultation'
|
||||
? '#ddd6fe'
|
||||
: purpose === 'filling'
|
||||
? '#fed7aa'
|
||||
: purpose === 'endo'
|
||||
? '#fecaca'
|
||||
: purpose === 'visit'
|
||||
? '#bae6fd'
|
||||
: '#d9f99d';
|
||||
const purposeTextColor = PURPOSE_OPTION_COLORS[purpose];
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
@@ -81,17 +86,18 @@ export function AppointmentBookingModal({
|
||||
scheduleDate.getFullYear(),
|
||||
scheduleDate.getMonth(),
|
||||
scheduleDate.getDate(),
|
||||
initialHour,
|
||||
0,
|
||||
Math.floor(initialStartMinute / 60),
|
||||
initialStartMinute % 60,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
const endMinute = Math.min(initialStartMinute + 60, 24 * 60 - 1);
|
||||
const end = new Date(
|
||||
scheduleDate.getFullYear(),
|
||||
scheduleDate.getMonth(),
|
||||
scheduleDate.getDate(),
|
||||
initialHour < 23 ? initialHour + 1 : 23,
|
||||
initialHour < 23 ? 0 : 59,
|
||||
Math.floor(endMinute / 60),
|
||||
endMinute % 60,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
@@ -100,7 +106,7 @@ export function AppointmentBookingModal({
|
||||
setPurpose('consultation');
|
||||
}
|
||||
setError('');
|
||||
}, [open, scheduleDate, initialHour, editingAppointment]);
|
||||
}, [open, scheduleDate, initialStartMinute, editingAppointment]);
|
||||
|
||||
if (!open || !providerUserId) {
|
||||
return null;
|
||||
@@ -115,7 +121,7 @@ export function AppointmentBookingModal({
|
||||
return;
|
||||
}
|
||||
if (!editingAppointment && !patient) {
|
||||
setError('Select a patient first.');
|
||||
setError(t('errorSelectPatient'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -123,26 +129,26 @@ export function AppointmentBookingModal({
|
||||
const endAt = combineLocalDateAndTime(scheduleDate, endTime);
|
||||
|
||||
if (endAt <= startAt) {
|
||||
setError('End time must be after start time.');
|
||||
setError(t('errorEndAfterStart'));
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
if (isSameLocalCalendarDay(scheduleDate, now) && startAt.getTime() < now.getTime()) {
|
||||
setError('Cannot schedule in the past.');
|
||||
setError(t('errorPastSchedule'));
|
||||
return;
|
||||
}
|
||||
|
||||
const today = new Date();
|
||||
if (compareLocalDayStart(scheduleDate, today) < 0) {
|
||||
setError('Past appointments are view-only.');
|
||||
setError(t('errorPastViewOnly'));
|
||||
return;
|
||||
}
|
||||
|
||||
const effectivePatientId = editingAppointment?.patientId ?? patient?.id;
|
||||
const effectiveProviderId = editingAppointment?.providerUserId ?? providerUserId;
|
||||
if (!effectivePatientId || !effectiveProviderId) {
|
||||
setError('Missing appointment details.');
|
||||
setError(t('errorMissingDetails'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -165,29 +171,34 @@ export function AppointmentBookingModal({
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h2 id="appointment-modal-title" className="text-lg font-semibold text-text-primary pr-2">
|
||||
{editingAppointment ? 'Edit appointment' : 'New appointment'}
|
||||
{editingAppointment ? t('editTitle') : t('newTitle')}
|
||||
</h2>
|
||||
<DialogCloseButton onClick={onClose} />
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-text-secondary">
|
||||
Provider: <span className="text-text-primary font-medium">{providerName}</span>
|
||||
{t('providerLabel')}{' '}
|
||||
<span className="text-text-primary font-medium">{providerName}</span>
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-secondary mb-1">Patient</label>
|
||||
<label className="block text-sm font-medium text-text-secondary mb-1">
|
||||
{t('patientLabel')}
|
||||
</label>
|
||||
<p className="text-sm text-text-primary rounded-[var(--radius-md)] border border-border bg-background-secondary/60 px-3 py-2">
|
||||
{editingAppointment
|
||||
? `${editingAppointment.patient.firstName} ${editingAppointment.patient.lastName}`
|
||||
: patient
|
||||
? `${patient.firstName} ${patient.lastName}`
|
||||
: '—'}
|
||||
: tPatients('emptyValue')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-secondary mb-1">Start</label>
|
||||
<label className="block text-sm font-medium text-text-secondary mb-1">
|
||||
{t('startLabel')}
|
||||
</label>
|
||||
<input
|
||||
type="time"
|
||||
step={60}
|
||||
@@ -197,7 +208,9 @@ export function AppointmentBookingModal({
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-secondary mb-1">End</label>
|
||||
<label className="block text-sm font-medium text-text-secondary mb-1">
|
||||
{t('endLabel')}
|
||||
</label>
|
||||
<input
|
||||
type="time"
|
||||
step={60}
|
||||
@@ -209,26 +222,20 @@ export function AppointmentBookingModal({
|
||||
</div>
|
||||
|
||||
<Dropdown
|
||||
label="Purpose"
|
||||
label={t('purposeLabel')}
|
||||
value={purpose}
|
||||
onChange={(e) => setPurpose(e.target.value as AppointmentPurpose)}
|
||||
style={{ color: purposeTextColor }}
|
||||
>
|
||||
<option value="consultation" style={{ color: '#ddd6fe', backgroundColor: '#14253d' }}>
|
||||
{APPOINTMENT_PURPOSE_LABEL.consultation}
|
||||
</option>
|
||||
<option value="filling" style={{ color: '#fed7aa', backgroundColor: '#14253d' }}>
|
||||
{APPOINTMENT_PURPOSE_LABEL.filling}
|
||||
</option>
|
||||
<option value="endo" style={{ color: '#fecaca', backgroundColor: '#14253d' }}>
|
||||
{APPOINTMENT_PURPOSE_LABEL.endo}
|
||||
</option>
|
||||
<option value="visit" style={{ color: '#bae6fd', backgroundColor: '#14253d' }}>
|
||||
{APPOINTMENT_PURPOSE_LABEL.visit}
|
||||
</option>
|
||||
<option value="hygiene" style={{ color: '#d9f99d', backgroundColor: '#14253d' }}>
|
||||
{APPOINTMENT_PURPOSE_LABEL.hygiene}
|
||||
</option>
|
||||
{APPOINTMENT_PURPOSES.map((purposeOption) => (
|
||||
<option
|
||||
key={purposeOption}
|
||||
value={purposeOption}
|
||||
style={{ color: PURPOSE_OPTION_COLORS[purposeOption], backgroundColor: '#14253d' }}
|
||||
>
|
||||
{getPurposeLabel(purposeOption, t)}
|
||||
</option>
|
||||
))}
|
||||
</Dropdown>
|
||||
|
||||
{error && <p className="text-sm text-red-400">{error}</p>}
|
||||
@@ -242,14 +249,14 @@ export function AppointmentBookingModal({
|
||||
disabled={loading || deleting}
|
||||
isLoading={deleting}
|
||||
>
|
||||
Delete
|
||||
{tCommon('delete')}
|
||||
</Button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<div className="flex gap-2 ml-auto">
|
||||
<Button type="button" variant="ghost" onClick={onClose} disabled={loading || deleting}>
|
||||
Cancel
|
||||
{tCommon('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -258,7 +265,7 @@ export function AppointmentBookingModal({
|
||||
isLoading={loading}
|
||||
disabled={deleting}
|
||||
>
|
||||
Save
|
||||
{tCommon('save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
|
||||
import {
|
||||
APPOINTMENT_PURPOSE_LABEL,
|
||||
getPurposeLabel,
|
||||
purposeStyle,
|
||||
} from '@/components/ui/appointments/appointmentPurposeStyles';
|
||||
import type { AppointmentRecord } from '@/types/appointment';
|
||||
import type { AppointmentPurpose, AppointmentRecord } from '@/types/appointment';
|
||||
|
||||
type AppointmentOverlapPopoverProps = {
|
||||
appointments: AppointmentRecord[];
|
||||
@@ -28,6 +29,7 @@ export function AppointmentOverlapPopover({
|
||||
onSelect,
|
||||
onClose,
|
||||
}: AppointmentOverlapPopoverProps) {
|
||||
const t = useTranslations('appointments');
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -75,13 +77,13 @@ export function AppointmentOverlapPopover({
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2 mb-2">
|
||||
<h3 id="overlap-popover-title" className="text-sm font-semibold text-text-primary pr-2">
|
||||
Overlapping appointments ({sorted.length})
|
||||
{t('overlappingTitle', { count: sorted.length })}
|
||||
</h3>
|
||||
<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 keyof typeof APPOINTMENT_PURPOSE_LABEL;
|
||||
const purpose = apt.purpose as AppointmentPurpose;
|
||||
return (
|
||||
<li key={apt.id}>
|
||||
<button
|
||||
@@ -97,7 +99,7 @@ export function AppointmentOverlapPopover({
|
||||
</p>
|
||||
<p className="text-[11px] opacity-90 tabular-nums">{formatTimeRange(apt)}</p>
|
||||
<p className="text-[10px] opacity-80 truncate">
|
||||
{APPOINTMENT_PURPOSE_LABEL[purpose] ?? apt.purpose}
|
||||
{getPurposeLabel(purpose, t) ?? apt.purpose}
|
||||
</p>
|
||||
</button>
|
||||
</li>
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import type { AppointmentColumnProvider, AppointmentRecord } from '@/types/appointment';
|
||||
import { formatHourLabel } from '@/components/appointments/appointmentTime';
|
||||
import {
|
||||
SCHEDULE_SLOT_MINUTES,
|
||||
appointmentWithinWorkingHours,
|
||||
formatMinuteLabel,
|
||||
generateHourLabelsInRange,
|
||||
generateSlotStarts,
|
||||
isSlotWithinWorkingBlocks,
|
||||
snapRangeToSlots,
|
||||
unionDayBlockRange,
|
||||
} from '@/components/staff/workingHours';
|
||||
import {
|
||||
computeAppointmentLaneLayouts,
|
||||
findOverlapCluster,
|
||||
@@ -10,23 +20,30 @@ import {
|
||||
} from '@/components/appointments/appointmentOverlapLayout';
|
||||
import { purposeStyle } from '@/components/ui/appointments/appointmentPurposeStyles';
|
||||
import { AppointmentOverlapPopover } from '@/components/ui/appointments/AppointmentOverlapPopover';
|
||||
import { startOfLocalDay } from '@/components/appointments/appointmentTime';
|
||||
|
||||
const HOUR_PX = 40;
|
||||
const HOURS = Array.from({ length: 24 }, (_, i) => i);
|
||||
const HOUR_PX = 80;
|
||||
const SLOT_PX = (HOUR_PX * SCHEDULE_SLOT_MINUTES) / 60;
|
||||
|
||||
function layoutBlock(apt: AppointmentRecord, day: Date): { top: string; height: string } | null {
|
||||
const dayStart = new Date(day.getFullYear(), day.getMonth(), day.getDate(), 0, 0, 0, 0);
|
||||
const dayEnd = new Date(day.getFullYear(), day.getMonth(), day.getDate() + 1, 0, 0, 0, 0);
|
||||
function layoutBlockInRange(
|
||||
apt: AppointmentRecord,
|
||||
day: Date,
|
||||
rangeStartMinute: number,
|
||||
rangeEndMinute: number,
|
||||
): { top: string; height: string } | null {
|
||||
const dayStart = startOfLocalDay(day);
|
||||
const rangeStartMs = dayStart.getTime() + rangeStartMinute * 60_000;
|
||||
const rangeEndMs = dayStart.getTime() + rangeEndMinute * 60_000;
|
||||
const start = new Date(apt.startAt);
|
||||
const end = new Date(apt.endAt);
|
||||
const ms = dayEnd.getTime() - dayStart.getTime();
|
||||
const clipStart = Math.max(start.getTime(), dayStart.getTime());
|
||||
const clipEnd = Math.min(end.getTime(), dayEnd.getTime());
|
||||
const clipStart = Math.max(start.getTime(), rangeStartMs);
|
||||
const clipEnd = Math.min(end.getTime(), rangeEndMs);
|
||||
if (clipEnd <= clipStart) {
|
||||
return null;
|
||||
}
|
||||
const top = ((clipStart - dayStart.getTime()) / ms) * 100;
|
||||
const height = ((clipEnd - clipStart) / ms) * 100;
|
||||
const rangeMs = rangeEndMs - rangeStartMs;
|
||||
const top = ((clipStart - rangeStartMs) / rangeMs) * 100;
|
||||
const height = ((clipEnd - clipStart) / rangeMs) * 100;
|
||||
return { top: `${top}%`, height: `${height}%` };
|
||||
}
|
||||
|
||||
@@ -36,12 +53,12 @@ function appointmentDurationMinutes(apt: AppointmentRecord): number {
|
||||
return Math.max(0, Math.round((end - start) / 60_000));
|
||||
}
|
||||
|
||||
function appointmentBannerHeightPx(durationMin: number): number {
|
||||
return (durationMin / (24 * 60)) * HOURS.length * HOUR_PX;
|
||||
function appointmentBannerHeightPx(durationMin: number, rangeMinutes: number, gridHeight: number): number {
|
||||
return (durationMin / rangeMinutes) * gridHeight;
|
||||
}
|
||||
|
||||
function shortBannerNameClass(durationMin: number): string {
|
||||
const heightPx = appointmentBannerHeightPx(durationMin);
|
||||
function shortBannerNameClass(durationMin: number, rangeMinutes: number, gridHeight: number): string {
|
||||
const heightPx = appointmentBannerHeightPx(durationMin, rangeMinutes, gridHeight);
|
||||
if (heightPx < 18) {
|
||||
return 'text-[8px] leading-none';
|
||||
}
|
||||
@@ -61,8 +78,9 @@ interface AppointmentScheduleGridProps {
|
||||
providers: AppointmentColumnProvider[];
|
||||
appointments: AppointmentRecord[];
|
||||
canBook: boolean;
|
||||
onSlotClick: (hour: number, providerUserId: string, providerName: string) => void;
|
||||
onSlotClick: (startMinute: number, providerUserId: string, providerName: string) => void;
|
||||
onAppointmentClick?: (appointment: AppointmentRecord) => void;
|
||||
onAppointmentOutsideHours?: (appointment: AppointmentRecord) => void;
|
||||
}
|
||||
|
||||
export function AppointmentScheduleGrid({
|
||||
@@ -72,10 +90,40 @@ export function AppointmentScheduleGrid({
|
||||
canBook,
|
||||
onSlotClick,
|
||||
onAppointmentClick,
|
||||
onAppointmentOutsideHours,
|
||||
}: AppointmentScheduleGridProps) {
|
||||
const gridHeight = HOURS.length * HOUR_PX;
|
||||
const t = useTranslations('appointments');
|
||||
const [overlapPopover, setOverlapPopover] = useState<OverlapPopoverState | null>(null);
|
||||
|
||||
const visibleRange = useMemo(() => {
|
||||
const activeDayBlocks = providers
|
||||
.filter((p) => p.hasWorkingHours && p.dayBlocks.length > 0)
|
||||
.map((p) => p.dayBlocks);
|
||||
return unionDayBlockRange(activeDayBlocks);
|
||||
}, [providers]);
|
||||
|
||||
const snappedRange = useMemo(() => {
|
||||
if (!visibleRange) return null;
|
||||
return snapRangeToSlots(visibleRange.startMinute, visibleRange.endMinute, SCHEDULE_SLOT_MINUTES);
|
||||
}, [visibleRange]);
|
||||
|
||||
const slotStarts = useMemo(() => {
|
||||
if (!snappedRange) return [];
|
||||
return generateSlotStarts(
|
||||
snappedRange.startMinute,
|
||||
snappedRange.endMinute,
|
||||
SCHEDULE_SLOT_MINUTES,
|
||||
);
|
||||
}, [snappedRange]);
|
||||
|
||||
const hourLabels = useMemo(() => {
|
||||
if (!snappedRange) return [];
|
||||
return generateHourLabelsInRange(snappedRange.startMinute, snappedRange.endMinute);
|
||||
}, [snappedRange]);
|
||||
|
||||
const gridHeight = slotStarts.length * SLOT_PX;
|
||||
const rangeMinutes = snappedRange ? snappedRange.endMinute - snappedRange.startMinute : 0;
|
||||
|
||||
const laneLayoutsByProvider = useMemo(() => {
|
||||
const map = new Map<string, ReturnType<typeof computeAppointmentLaneLayouts>>();
|
||||
for (const provider of providers) {
|
||||
@@ -87,9 +135,19 @@ export function AppointmentScheduleGrid({
|
||||
|
||||
function handleAppointmentBannerClick(
|
||||
apt: AppointmentRecord,
|
||||
provider: AppointmentColumnProvider,
|
||||
providerAppointments: AppointmentRecord[],
|
||||
anchor: HTMLElement,
|
||||
) {
|
||||
if (
|
||||
provider.hasWorkingHours &&
|
||||
provider.dayBlocks.length > 0 &&
|
||||
!appointmentWithinWorkingHours(new Date(apt.startAt), new Date(apt.endAt), provider.dayBlocks)
|
||||
) {
|
||||
onAppointmentOutsideHours?.(apt);
|
||||
return;
|
||||
}
|
||||
|
||||
const cluster = findOverlapCluster(apt.id, providerAppointments);
|
||||
if (cluster.length > 1) {
|
||||
setOverlapPopover({
|
||||
@@ -103,9 +161,13 @@ export function AppointmentScheduleGrid({
|
||||
|
||||
if (providers.length === 0) {
|
||||
return (
|
||||
<div className="surface-card p-6 text-sm text-text-muted">
|
||||
No providers available. Add staff with treatment edit access to see columns here.
|
||||
</div>
|
||||
<div className="surface-card p-6 text-sm text-text-muted">{t('noProviders')}</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!snappedRange || slotStarts.length === 0) {
|
||||
return (
|
||||
<div className="surface-card p-6 text-sm text-text-muted">{t('noWorkingHours')}</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -121,21 +183,38 @@ export function AppointmentScheduleGrid({
|
||||
className="flex-1 min-w-[130px] text-center text-sm font-medium text-text-primary py-2.5 px-1 border-l border-border"
|
||||
>
|
||||
{p.name}
|
||||
{!p.hasWorkingHours && (
|
||||
<span className="block text-[10px] font-normal text-text-muted mt-0.5">
|
||||
{t('noHoursSet')}
|
||||
</span>
|
||||
)}
|
||||
{p.hasWorkingHours && p.dayBlocks.length === 0 && (
|
||||
<span className="block text-[10px] font-normal text-text-muted mt-0.5">
|
||||
{t('offToday')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex">
|
||||
<div className="w-14 flex-shrink-0 border-r border-border bg-background-secondary/40">
|
||||
{HOURS.map((h) => (
|
||||
<div
|
||||
key={h}
|
||||
className="text-[11px] text-text-muted flex items-start justify-end pr-1.5 pt-0.5 border-b border-border/50"
|
||||
style={{ height: HOUR_PX }}
|
||||
>
|
||||
{formatHourLabel(h)}
|
||||
</div>
|
||||
))}
|
||||
<div
|
||||
className="w-14 flex-shrink-0 border-r border-border bg-background-secondary/40 relative"
|
||||
style={{ height: gridHeight }}
|
||||
>
|
||||
{hourLabels.map((hour) => {
|
||||
const top = ((hour * 60 - snappedRange.startMinute) / rangeMinutes) * gridHeight;
|
||||
const height = (60 / rangeMinutes) * gridHeight;
|
||||
return (
|
||||
<div
|
||||
key={hour}
|
||||
className="absolute left-0 right-0 text-[11px] text-text-muted flex items-start justify-end pr-1.5 pt-0.5 border-b border-border/50"
|
||||
style={{ top, height }}
|
||||
>
|
||||
{formatMinuteLabel(hour * 60)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex min-w-0">
|
||||
@@ -144,6 +223,7 @@ export function AppointmentScheduleGrid({
|
||||
(a) => a.providerUserId === p.userId,
|
||||
);
|
||||
const laneLayouts = laneLayoutsByProvider.get(p.userId) ?? new Map();
|
||||
const columnFullyDisabled = !p.hasWorkingHours || p.dayBlocks.length === 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -151,31 +231,52 @@ export function AppointmentScheduleGrid({
|
||||
className="flex-1 min-w-[130px] border-l border-border relative"
|
||||
style={{ height: gridHeight }}
|
||||
>
|
||||
{HOURS.map((h) => {
|
||||
const slotDisabled = !canBook;
|
||||
{slotStarts.map((slotStartMinute, index) => {
|
||||
const slotActive =
|
||||
!columnFullyDisabled &&
|
||||
isSlotWithinWorkingBlocks(
|
||||
slotStartMinute,
|
||||
SCHEDULE_SLOT_MINUTES,
|
||||
p.dayBlocks,
|
||||
);
|
||||
const slotDisabled = !canBook || columnFullyDisabled || !slotActive;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={h}
|
||||
key={`${p.userId}-${slotStartMinute}`}
|
||||
type="button"
|
||||
disabled={slotDisabled}
|
||||
title={
|
||||
slotDisabled
|
||||
? 'You cannot create appointments'
|
||||
: `Book ${formatHourLabel(h)}`
|
||||
columnFullyDisabled
|
||||
? p.hasWorkingHours
|
||||
? t('slotOffToday')
|
||||
: t('slotHoursNotConfigured')
|
||||
: !slotActive
|
||||
? t('slotOutsideHours')
|
||||
: slotDisabled
|
||||
? t('slotCannotCreate')
|
||||
: t('slotBookAt', {
|
||||
time: formatMinuteLabel(slotStartMinute),
|
||||
})
|
||||
}
|
||||
className={`absolute left-0 right-0 border-b border-border/50 transition-colors ${
|
||||
slotDisabled
|
||||
? 'cursor-not-allowed opacity-50'
|
||||
? 'cursor-not-allowed bg-background-secondary/35 opacity-60'
|
||||
: 'hover:bg-primary/8 cursor-pointer'
|
||||
}`}
|
||||
style={{ top: h * HOUR_PX, height: HOUR_PX }}
|
||||
onClick={() => onSlotClick(h, p.userId, p.name)}
|
||||
style={{ top: index * SLOT_PX, height: SLOT_PX }}
|
||||
onClick={() => onSlotClick(slotStartMinute, p.userId, p.name)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{providerAppointments.map((apt) => {
|
||||
const pos = layoutBlock(apt, day);
|
||||
const pos = layoutBlockInRange(
|
||||
apt,
|
||||
day,
|
||||
snappedRange.startMinute,
|
||||
snappedRange.endMinute,
|
||||
);
|
||||
if (!pos) {
|
||||
return null;
|
||||
}
|
||||
@@ -184,10 +285,21 @@ export function AppointmentScheduleGrid({
|
||||
const durationMin = appointmentDurationMinutes(apt);
|
||||
const clusterSize = findOverlapCluster(apt.id, providerAppointments).length;
|
||||
const isUnderOneHour = durationMin < 60;
|
||||
const outsideHours =
|
||||
p.hasWorkingHours &&
|
||||
p.dayBlocks.length > 0 &&
|
||||
!appointmentWithinWorkingHours(
|
||||
new Date(apt.startAt),
|
||||
new Date(apt.endAt),
|
||||
p.dayBlocks,
|
||||
);
|
||||
const patientName = `${apt.patient.firstName} ${apt.patient.lastName}`;
|
||||
const bannerTitle = [
|
||||
patientName,
|
||||
clusterSize > 1 ? `${clusterSize} overlapping — click to choose` : null,
|
||||
outsideHours ? t('outsideHoursBlocked') : null,
|
||||
clusterSize > 1
|
||||
? t('overlappingChoose', { count: clusterSize })
|
||||
: null,
|
||||
!isUnderOneHour && apt.patient.phone ? apt.patient.phone : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
@@ -198,9 +310,16 @@ export function AppointmentScheduleGrid({
|
||||
type="button"
|
||||
key={apt.id}
|
||||
onClick={(e) =>
|
||||
handleAppointmentBannerClick(apt, providerAppointments, e.currentTarget)
|
||||
handleAppointmentBannerClick(
|
||||
apt,
|
||||
p,
|
||||
providerAppointments,
|
||||
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 ${
|
||||
outsideHours ? 'opacity-70 ring-1 ring-amber-500/60' : ''
|
||||
} ${
|
||||
isUnderOneHour
|
||||
? 'items-center justify-center px-0.5 py-0'
|
||||
: 'flex-col justify-start gap-0.5 px-1 py-0.5'
|
||||
@@ -214,7 +333,7 @@ export function AppointmentScheduleGrid({
|
||||
title={bannerTitle}
|
||||
>
|
||||
<span
|
||||
className={`block w-full truncate pointer-events-none font-medium ${shortBannerNameClass(durationMin)}`}
|
||||
className={`block w-full truncate pointer-events-none font-medium ${shortBannerNameClass(durationMin, rangeMinutes, gridHeight)}`}
|
||||
>
|
||||
{patientName}
|
||||
</span>
|
||||
@@ -227,7 +346,7 @@ export function AppointmentScheduleGrid({
|
||||
)}
|
||||
{!isUnderOneHour && clusterSize > 1 && (
|
||||
<span className="block w-full truncate pointer-events-none text-[9px] leading-tight opacity-75">
|
||||
{clusterSize} overlapping
|
||||
{t('overlapping', { count: clusterSize })}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
@@ -245,7 +364,24 @@ export function AppointmentScheduleGrid({
|
||||
<AppointmentOverlapPopover
|
||||
appointments={overlapPopover.appointments}
|
||||
anchorRect={overlapPopover.anchorRect}
|
||||
onSelect={(apt) => onAppointmentClick?.(apt)}
|
||||
onSelect={(apt) => {
|
||||
const provider = providers.find((p) => p.userId === apt.providerUserId);
|
||||
if (
|
||||
provider &&
|
||||
provider.hasWorkingHours &&
|
||||
provider.dayBlocks.length > 0 &&
|
||||
!appointmentWithinWorkingHours(
|
||||
new Date(apt.startAt),
|
||||
new Date(apt.endAt),
|
||||
provider.dayBlocks,
|
||||
)
|
||||
) {
|
||||
onAppointmentOutsideHours?.(apt);
|
||||
setOverlapPopover(null);
|
||||
return;
|
||||
}
|
||||
onAppointmentClick?.(apt);
|
||||
}}
|
||||
onClose={() => setOverlapPopover(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import {
|
||||
APPOINTMENT_PURPOSE_LABEL,
|
||||
APPOINTMENT_PURPOSE_LEGEND_SWATCH,
|
||||
getPurposeLabel,
|
||||
} from '@/components/ui/appointments/appointmentPurposeStyles';
|
||||
import { APPOINTMENT_PURPOSES } from '@/types/appointment';
|
||||
|
||||
export function AppointmentScheduleLegend() {
|
||||
const t = useTranslations('appointments');
|
||||
|
||||
return (
|
||||
<div className="surface-panel px-4 py-3">
|
||||
<p className="text-xs font-medium text-text-secondary mb-2">Legend</p>
|
||||
<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">
|
||||
<span
|
||||
className={`inline-block h-3 w-3 rounded-sm border ${APPOINTMENT_PURPOSE_LEGEND_SWATCH[p]}`}
|
||||
/>
|
||||
{APPOINTMENT_PURPOSE_LABEL[p]}
|
||||
{getPurposeLabel(p, t)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { Search } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Button } from '@/components/ui/shared/Button';
|
||||
import { Input } from '@/components/ui/shared/Input';
|
||||
import type { Patient } from '@/types/patient';
|
||||
@@ -26,6 +27,9 @@ export function AppointmentsPatientSearch({
|
||||
canAddPatient,
|
||||
onAddPatient,
|
||||
}: AppointmentsPatientSearchProps) {
|
||||
const t = useTranslations('appointments');
|
||||
const tPatients = useTranslations('patients');
|
||||
|
||||
const trimmed = search.trim();
|
||||
const showAddForEmptyResults =
|
||||
trimmed.length > 0 && !loading && patients.length === 0;
|
||||
@@ -35,7 +39,7 @@ export function AppointmentsPatientSearch({
|
||||
<div className="flex flex-col sm:flex-row gap-3 sm:items-center">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
placeholder="Search existing patients"
|
||||
placeholder={t('searchPlaceholder')}
|
||||
value={search}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
icon={<Search className="h-4 w-4 icon-flat" />}
|
||||
@@ -47,18 +51,18 @@ export function AppointmentsPatientSearch({
|
||||
variant="primary"
|
||||
disabled={!canAddPatient}
|
||||
onClick={onAddPatient}
|
||||
title={!canAddPatient ? 'You do not have permission to add patients.' : undefined}
|
||||
title={!canAddPatient ? t('noPermissionAdd') : undefined}
|
||||
>
|
||||
New Patient
|
||||
{tPatients('newPatient')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 max-h-72 overflow-y-auto">
|
||||
{loading && <p className="text-sm text-text-muted">Searching…</p>}
|
||||
{loading && <p className="text-sm text-text-muted">{t('searching')}</p>}
|
||||
|
||||
{!loading && trimmed.length === 0 && (
|
||||
<p className="text-sm text-text-muted">Type to search patients by name, phone, or email.</p>
|
||||
<p className="text-sm text-text-muted">{t('searchHint')}</p>
|
||||
)}
|
||||
|
||||
{patients.map((patient) => {
|
||||
@@ -77,7 +81,9 @@ export function AppointmentsPatientSearch({
|
||||
<p className="text-sm font-medium text-text-primary">
|
||||
{patient.firstName} {patient.lastName}
|
||||
</p>
|
||||
<p className="text-xs text-text-muted">{patient.phone || patient.email || 'No contact'}</p>
|
||||
<p className="text-xs text-text-muted">
|
||||
{patient.phone || patient.email || tPatients('noContact')}
|
||||
</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,12 +1,25 @@
|
||||
import type { AppointmentPurpose } from '@/types/appointment';
|
||||
|
||||
export const APPOINTMENT_PURPOSE_LABEL: Record<AppointmentPurpose, string> = {
|
||||
consultation: 'Consultation',
|
||||
filling: 'Filling',
|
||||
endo: 'Endo',
|
||||
visit: 'Visit',
|
||||
hygiene: 'Hygiene',
|
||||
};
|
||||
export const APPOINTMENT_PURPOSE_LABEL_KEYS = {
|
||||
consultation: 'purposeConsultation',
|
||||
filling: 'purposeFilling',
|
||||
endo: 'purposeEndo',
|
||||
visit: 'purposeVisit',
|
||||
hygiene: 'purposeHygiene',
|
||||
} as const satisfies Record<AppointmentPurpose, string>;
|
||||
|
||||
export type AppointmentPurposeLabelKey =
|
||||
(typeof APPOINTMENT_PURPOSE_LABEL_KEYS)[AppointmentPurpose];
|
||||
|
||||
export type AppointmentPurposeTranslate = (key: AppointmentPurposeLabelKey) => string;
|
||||
|
||||
export function getPurposeLabel(
|
||||
purpose: AppointmentPurpose,
|
||||
t: AppointmentPurposeTranslate,
|
||||
): string {
|
||||
const key = APPOINTMENT_PURPOSE_LABEL_KEYS[purpose];
|
||||
return key ? t(key) : purpose;
|
||||
}
|
||||
|
||||
/** Background + border for blocks / legend (matches reference palette). */
|
||||
export const APPOINTMENT_PURPOSE_STYLES: Record<AppointmentPurpose, string> = {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Building2, Mail } from 'lucide-react';
|
||||
import type { FieldErrors, UseFormRegister, UseFormSetValue } from 'react-hook-form';
|
||||
import { Input } from '@/components/ui/shared/Input';
|
||||
@@ -23,26 +24,28 @@ export function OrganizationDetailsFields({
|
||||
organizationType,
|
||||
setValue,
|
||||
}: OrganizationDetailsFieldsProps) {
|
||||
const t = useTranslations('auth');
|
||||
|
||||
return (
|
||||
<>
|
||||
<Input
|
||||
label="Organization name"
|
||||
label={t('organizationName')}
|
||||
{...register('organizationName')}
|
||||
placeholder="Sunshine Dental Clinic"
|
||||
placeholder={t('organizationNamePlaceholder')}
|
||||
error={errors.organizationName?.message}
|
||||
icon={<Building2 className="h-5 w-5 icon-flat" />}
|
||||
/>
|
||||
<Input
|
||||
label="Organization email"
|
||||
label={t('organizationEmail')}
|
||||
{...register('organizationEmail')}
|
||||
type="email"
|
||||
placeholder="contact@sunshineclinic.com"
|
||||
placeholder={t('organizationEmailPlaceholder')}
|
||||
error={errors.organizationEmail?.message}
|
||||
icon={<Mail className="h-5 w-5 icon-flat" />}
|
||||
/>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-secondary mb-2">
|
||||
Organization type
|
||||
{t('organizationType')}
|
||||
</label>
|
||||
<input type="hidden" {...register('organizationType')} />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
@@ -58,7 +61,7 @@ export function OrganizationDetailsFields({
|
||||
}`}
|
||||
>
|
||||
<Building2 className="h-8 w-8 mx-auto mb-2 icon-flat" />
|
||||
<span className="text-sm font-medium">Dental Clinic</span>
|
||||
<span className="text-sm font-medium">{t('dentalClinic')}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -72,7 +75,7 @@ export function OrganizationDetailsFields({
|
||||
}`}
|
||||
>
|
||||
<Building2 className="h-8 w-8 mx-auto mb-2 icon-flat" />
|
||||
<span className="text-sm font-medium">Dental Lab</span>
|
||||
<span className="text-sm font-medium">{t('dentalLab')}</span>
|
||||
</button>
|
||||
</div>
|
||||
{errors.organizationType && (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
|
||||
type RegistrationProgressStepsProps = {
|
||||
@@ -10,9 +11,11 @@ type RegistrationProgressStepsProps = {
|
||||
|
||||
export function RegistrationProgressSteps({
|
||||
step,
|
||||
firstLabel = 'Account',
|
||||
secondLabel = 'Organization',
|
||||
firstLabel,
|
||||
secondLabel,
|
||||
}: RegistrationProgressStepsProps) {
|
||||
const t = useTranslations('auth');
|
||||
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -31,7 +34,7 @@ export function RegistrationProgressSteps({
|
||||
step >= 1 ? 'text-primary' : 'text-text-muted'
|
||||
}`}
|
||||
>
|
||||
{firstLabel}
|
||||
{firstLabel ?? t('stepAccount')}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="h-5 w-5 text-text-muted icon-flat" />
|
||||
@@ -50,7 +53,7 @@ export function RegistrationProgressSteps({
|
||||
step >= 2 ? 'text-primary' : 'text-text-muted'
|
||||
}`}
|
||||
>
|
||||
{secondLabel}
|
||||
{secondLabel ?? t('stepOrganization')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Link } from '@/i18n/navigation';
|
||||
import {
|
||||
Settings,
|
||||
AlertTriangle,
|
||||
@@ -15,16 +16,21 @@ import { useAuth } from '@/lib/hooks/useAuth';
|
||||
import { authApi } from '@/lib/api/auth';
|
||||
import type { SubscriptionAlertData } from '@/types/subscription';
|
||||
|
||||
function warningTooltip(data: SubscriptionAlertData | null): string {
|
||||
function warningTooltip(
|
||||
data: SubscriptionAlertData | null,
|
||||
t: ReturnType<typeof useTranslations<'accountMenu'>>,
|
||||
): string {
|
||||
if (!data?.showWarning) return '';
|
||||
if (data.noActiveSubscription) return 'No active subscription — review Subscriptions';
|
||||
if (data.trialExpired) return 'Trial ended — review Subscriptions';
|
||||
if (data.trialEndingSoon) return 'Trial ending soon — review Subscriptions';
|
||||
if (data.seatsLow) return 'Seats running low — review Subscriptions';
|
||||
return 'Review Subscriptions';
|
||||
if (data.noActiveSubscription) return t('noActiveSubscription');
|
||||
if (data.trialExpired) return t('trialEnded');
|
||||
if (data.trialEndingSoon) return t('trialEndingSoon');
|
||||
if (data.seatsLow) return t('seatsLow');
|
||||
return t('reviewSubscriptions');
|
||||
}
|
||||
|
||||
export function DashboardAccountMenu() {
|
||||
const t = useTranslations('auth');
|
||||
const tAccount = useTranslations('accountMenu');
|
||||
const { user, currentOrganization, logout } = useAuth();
|
||||
const [open, setOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
@@ -61,7 +67,7 @@ export function DashboardAccountMenu() {
|
||||
}, [isOwner, currentOrganization?.id]);
|
||||
|
||||
const showWarning = Boolean(isOwner && alert?.showWarning);
|
||||
const tooltip = useMemo(() => warningTooltip(alert), [alert]);
|
||||
const tooltip = useMemo(() => warningTooltip(alert, tAccount), [alert, tAccount]);
|
||||
|
||||
const handleLogout = useCallback(() => {
|
||||
setOpen(false);
|
||||
@@ -98,7 +104,7 @@ export function DashboardAccountMenu() {
|
||||
className="absolute right-0 mt-2 w-72 rounded-[var(--radius-md)] border border-border bg-background-secondary/95 py-2 shadow-lg z-[200] backdrop-blur-sm"
|
||||
>
|
||||
<div className="px-3 py-2 border-b border-border/60">
|
||||
<p className="text-xs text-text-muted">Signed in</p>
|
||||
<p className="text-xs text-text-muted">{t('signedIn')}</p>
|
||||
<p className="text-sm font-medium truncate">{user?.email}</p>
|
||||
<p className="text-xs text-text-secondary mt-1 truncate">
|
||||
{currentOrganization?.name}
|
||||
@@ -113,7 +119,7 @@ export function DashboardAccountMenu() {
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<Building2 className="h-4 w-4 icon-flat shrink-0" />
|
||||
Switch organization
|
||||
{t('switchOrganization')}
|
||||
</Link>
|
||||
|
||||
{isOwner && (
|
||||
@@ -124,7 +130,7 @@ export function DashboardAccountMenu() {
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<CreditCard className="h-4 w-4 icon-flat shrink-0" />
|
||||
Subscriptions
|
||||
{t('subscriptions')}
|
||||
</Link>
|
||||
)}
|
||||
|
||||
@@ -135,7 +141,7 @@ export function DashboardAccountMenu() {
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<User className="h-4 w-4 icon-flat shrink-0" />
|
||||
Account
|
||||
{t('account')}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -147,7 +153,7 @@ export function DashboardAccountMenu() {
|
||||
onClick={handleLogout}
|
||||
>
|
||||
<LogOut className="h-4 w-4 icon-flat shrink-0" />
|
||||
Log out
|
||||
{t('signOut')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Check, Copy } from 'lucide-react';
|
||||
import {
|
||||
canShareOrganizationInviteLink,
|
||||
@@ -19,6 +20,8 @@ export function CopyInvitationLinkButton({
|
||||
copying,
|
||||
onCopy,
|
||||
}: CopyInvitationLinkButtonProps) {
|
||||
const t = useTranslations('organizations');
|
||||
|
||||
if (!canShareOrganizationInviteLink(invitation)) {
|
||||
return <span className="text-xs text-text-muted">—</span>;
|
||||
}
|
||||
@@ -29,8 +32,8 @@ export function CopyInvitationLinkButton({
|
||||
className="p-2 rounded-md text-text-secondary hover:bg-background-card/80 hover:text-text-primary disabled:opacity-50"
|
||||
disabled={copying}
|
||||
onClick={onCopy}
|
||||
aria-label="Copy invitation link"
|
||||
title="Copy invitation link (generates a new link if needed)"
|
||||
aria-label={t('copyInvitationLink')}
|
||||
title={t('copyInvitationLinkTitle')}
|
||||
>
|
||||
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
|
||||
</button>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
|
||||
import { ToastStack, type ToastMessages } from '@/components/ui/shared/Toast';
|
||||
import type { OrganizationInvitationHistoryItemDto } from '@/lib/api/organization';
|
||||
@@ -7,14 +8,6 @@ import { Badge, organizationConnectionStatusVariant } from '@/components/ui/shar
|
||||
import { Table } from '@/components/ui/shared/Table';
|
||||
import { CopyInvitationLinkButton } from '@/components/ui/organizations/CopyInvitationLinkButton';
|
||||
|
||||
function formatInvitationStatusLabel(status: OrganizationInvitationHistoryItemDto['status']): string {
|
||||
if (status === 'PENDING') return 'Invitation pending';
|
||||
if (status === 'ACTIVE') return 'Invitation accepted';
|
||||
if (status === 'REJECTED') return 'Invitation rejected';
|
||||
if (status === 'EXPIRED') return 'Invitation expired';
|
||||
return status;
|
||||
}
|
||||
|
||||
function formatTableDate(value: string): string {
|
||||
const d = new Date(value);
|
||||
if (Number.isNaN(d.getTime())) return '—';
|
||||
@@ -43,6 +36,18 @@ export function InvitationHistoryDialog({
|
||||
onCopy,
|
||||
toastMessages,
|
||||
}: InvitationHistoryDialogProps) {
|
||||
const t = useTranslations('organizations');
|
||||
|
||||
function formatInvitationStatusLabel(
|
||||
status: OrganizationInvitationHistoryItemDto['status'],
|
||||
): string {
|
||||
if (status === 'PENDING') return t('statusPending');
|
||||
if (status === 'ACTIVE') return t('statusAccepted');
|
||||
if (status === 'REJECTED') return t('statusRejected');
|
||||
if (status === 'EXPIRED') return t('statusExpired');
|
||||
return status;
|
||||
}
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
@@ -55,7 +60,7 @@ export function InvitationHistoryDialog({
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<h2 id="invitation-history-title" className="text-lg font-semibold text-text-primary pr-2">
|
||||
Invitation History
|
||||
{t('historyTitle')}
|
||||
</h2>
|
||||
<DialogCloseButton onClick={onClose} />
|
||||
</div>
|
||||
@@ -63,27 +68,27 @@ export function InvitationHistoryDialog({
|
||||
{toastMessages && <ToastStack {...toastMessages} />}
|
||||
|
||||
{loading ? (
|
||||
<p className="text-sm text-text-secondary">Loading invitation history...</p>
|
||||
<p className="text-sm text-text-secondary">{t('loadingHistory')}</p>
|
||||
) : items.length === 0 ? (
|
||||
<p className="text-sm text-text-secondary">No invitations yet.</p>
|
||||
<p className="text-sm text-text-secondary">{t('historyEmpty')}</p>
|
||||
) : (
|
||||
<Table
|
||||
headers={
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
|
||||
Organization
|
||||
{t('tableOrganization')}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
|
||||
Owner email
|
||||
{t('tableOwnerEmail')}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
|
||||
Date
|
||||
{t('tableDate')}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-center text-xs font-medium text-text-muted uppercase tracking-wider">
|
||||
Status
|
||||
{t('tableStatus')}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-text-muted uppercase tracking-wider">
|
||||
Invitation link
|
||||
{t('tableInvitationLink')}
|
||||
</th>
|
||||
</tr>
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useAuth } from '@/lib/hooks/useAuth';
|
||||
import { canCreateOrganizationFromCurrentOrg } from '@/components/shared/permissions';
|
||||
import { Building2, Beaker, Mail } from 'lucide-react';
|
||||
@@ -8,6 +9,9 @@ import { Input } from '@/components/ui/shared/Input';
|
||||
import { Button } from '@/components/ui/shared/Button';
|
||||
|
||||
export function OrganizationSelectorContent() {
|
||||
const t = useTranslations('organizations');
|
||||
const tAuth = useTranslations('auth');
|
||||
const tCommon = useTranslations('common');
|
||||
const {
|
||||
organizations,
|
||||
currentOrganization,
|
||||
@@ -29,6 +33,9 @@ export function OrganizationSelectorContent() {
|
||||
const getIcon = (type: string) =>
|
||||
type === 'CLINIC' ? <Building2 className="h-8 w-8 icon-flat" /> : <Beaker className="h-8 w-8 icon-flat" />;
|
||||
|
||||
const getTypeLabel = (type: string) =>
|
||||
type === 'CLINIC' ? tAuth('dentalClinic') : tAuth('dentalLab');
|
||||
|
||||
const handleCreateOrganization = async () => {
|
||||
try {
|
||||
clearError();
|
||||
@@ -48,18 +55,16 @@ export function OrganizationSelectorContent() {
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <p className="text-text-secondary">Loading...</p>;
|
||||
return <p className="text-text-secondary">{tCommon('loadingEllipsis')}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold text-text-primary">Organizations</h1>
|
||||
<h1 className="text-3xl font-semibold text-text-primary">{t('selectorTitle')}</h1>
|
||||
<p className="text-text-secondary mt-2">
|
||||
{canCreateOrganization
|
||||
? 'Select an organization to continue, or create a new one.'
|
||||
: 'Select an organization to continue.'}
|
||||
{canCreateOrganization ? t('selectorSubtitleWithCreate') : t('selectorSubtitleSelectOnly')}
|
||||
</p>
|
||||
</div>
|
||||
{canCreateOrganization && (
|
||||
@@ -71,7 +76,7 @@ export function OrganizationSelectorContent() {
|
||||
setIsCreateOpen((prev) => !prev);
|
||||
}}
|
||||
>
|
||||
{isCreateOpen ? 'Cancel' : 'Create Organization'}
|
||||
{isCreateOpen ? tCommon('cancel') : t('createOrganization')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -79,23 +84,23 @@ export function OrganizationSelectorContent() {
|
||||
{canCreateOrganization && isCreateOpen && (
|
||||
<div className="surface-card p-6 space-y-4">
|
||||
<Input
|
||||
label="Organization name"
|
||||
label={tAuth('organizationName')}
|
||||
value={organizationName}
|
||||
onChange={(event) => setOrganizationName(event.target.value)}
|
||||
placeholder="Sunshine Dental Clinic"
|
||||
placeholder={tAuth('organizationNamePlaceholder')}
|
||||
icon={<Building2 className="h-5 w-5 icon-flat" />}
|
||||
/>
|
||||
<Input
|
||||
label="Organization email"
|
||||
label={tAuth('organizationEmail')}
|
||||
value={organizationEmail}
|
||||
onChange={(event) => setOrganizationEmail(event.target.value)}
|
||||
placeholder="contact@sunshineclinic.com"
|
||||
placeholder={tAuth('organizationEmailPlaceholder')}
|
||||
type="email"
|
||||
icon={<Mail className="h-5 w-5 icon-flat" />}
|
||||
/>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-secondary mb-2">
|
||||
Organization type
|
||||
{tAuth('organizationType')}
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
@@ -107,7 +112,7 @@ export function OrganizationSelectorContent() {
|
||||
: 'border-border text-text-secondary hover:border-border-strong'
|
||||
}`}
|
||||
>
|
||||
Dental Clinic
|
||||
{tAuth('dentalClinic')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -118,7 +123,7 @@ export function OrganizationSelectorContent() {
|
||||
: 'border-border text-text-secondary hover:border-border-strong'
|
||||
}`}
|
||||
>
|
||||
Dental Lab
|
||||
{tAuth('dentalLab')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -135,7 +140,7 @@ export function OrganizationSelectorContent() {
|
||||
isLoading={isLoading}
|
||||
disabled={!organizationName.trim() || !organizationEmail.trim()}
|
||||
>
|
||||
Create and Continue
|
||||
{t('createAndContinue')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -144,9 +149,7 @@ export function OrganizationSelectorContent() {
|
||||
{!organizations.length ? (
|
||||
<div className="surface-card p-8 text-center">
|
||||
<p className="text-text-secondary">
|
||||
{canCreateOrganization
|
||||
? 'No organizations found. Create your first one to continue.'
|
||||
: 'No organizations found. Ask an organization owner to invite you.'}
|
||||
{canCreateOrganization ? t('emptyCanCreate') : t('emptyAskOwner')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -166,12 +169,12 @@ export function OrganizationSelectorContent() {
|
||||
{org.name}
|
||||
</h3>
|
||||
<p className="text-sm text-text-secondary">
|
||||
{org.type === 'CLINIC' ? 'Dental Clinic' : 'Dental Lab'}
|
||||
{getTypeLabel(org.type)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-primary text-sm">
|
||||
Continue →
|
||||
{t('continueArrow')}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Button } from '@/components/ui/shared/Button';
|
||||
import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
|
||||
import { Input } from '@/components/ui/shared/Input';
|
||||
@@ -31,26 +32,29 @@ function CreatePatientFormFields({
|
||||
loading: boolean;
|
||||
showCancel: boolean;
|
||||
}) {
|
||||
const t = useTranslations('patients');
|
||||
const tCommon = useTranslations('common');
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<Input
|
||||
label="First name"
|
||||
label={t('firstName')}
|
||||
value={formData.firstName || ''}
|
||||
onChange={(e) => onChange({ firstName: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
label="Last name"
|
||||
label={t('lastName')}
|
||||
value={formData.lastName || ''}
|
||||
onChange={(e) => onChange({ lastName: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
label="Phone"
|
||||
label={t('phone')}
|
||||
value={formData.phone || ''}
|
||||
onChange={(e) => onChange({ phone: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
label="Email"
|
||||
label={tCommon('email')}
|
||||
type="email"
|
||||
value={formData.email || ''}
|
||||
onChange={(e) => onChange({ email: e.target.value })}
|
||||
@@ -64,11 +68,11 @@ function CreatePatientFormFields({
|
||||
isLoading={loading}
|
||||
disabled={!formData.firstName || !formData.lastName}
|
||||
>
|
||||
Save Patient
|
||||
{t('savePatient')}
|
||||
</Button>
|
||||
{showCancel && (
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
Cancel
|
||||
{tCommon('cancel')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -85,6 +89,8 @@ export function CreatePatientModal({
|
||||
loading = false,
|
||||
variant = 'inline',
|
||||
}: CreatePatientModalProps) {
|
||||
const t = useTranslations('patients');
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
@@ -112,7 +118,7 @@ export function CreatePatientModal({
|
||||
id="create-patient-dialog-title"
|
||||
className="text-lg font-semibold text-text-primary pr-2"
|
||||
>
|
||||
New patient
|
||||
{t('dialogTitle')}
|
||||
</h2>
|
||||
<DialogCloseButton onClick={onClose} />
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Search } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/shared/Input';
|
||||
import { Patient } from '@/types/patient';
|
||||
@@ -21,20 +22,22 @@ export function PatientSearchSelect({
|
||||
onSelectPatient,
|
||||
loading = false,
|
||||
}: PatientSearchSelectProps) {
|
||||
const t = useTranslations('patients');
|
||||
|
||||
return (
|
||||
<div className="surface-card p-4 space-y-4">
|
||||
<Input
|
||||
placeholder="Search patients by name, phone, email"
|
||||
placeholder={t('searchPlaceholder')}
|
||||
value={search}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
icon={<Search className="h-4 w-4 icon-flat" />}
|
||||
/>
|
||||
|
||||
<div className="space-y-2 max-h-80 overflow-y-auto">
|
||||
{loading && <p className="text-sm text-text-muted">Loading patients...</p>}
|
||||
{loading && <p className="text-sm text-text-muted">{t('loadingPatients')}</p>}
|
||||
|
||||
{!loading && patients.length === 0 && (
|
||||
<p className="text-sm text-text-muted">No patients found for this search.</p>
|
||||
<p className="text-sm text-text-muted">{t('noResults')}</p>
|
||||
)}
|
||||
|
||||
{patients.map((patient) => {
|
||||
@@ -53,7 +56,7 @@ export function PatientSearchSelect({
|
||||
<p className="text-sm font-medium text-text-primary">
|
||||
{patient.firstName} {patient.lastName}
|
||||
</p>
|
||||
<p className="text-xs text-text-muted">{patient.phone || patient.email || 'No contact'}</p>
|
||||
<p className="text-xs text-text-muted">{patient.phone || patient.email || t('noContact')}</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Patient } from '@/types/patient';
|
||||
|
||||
interface PatientSummaryCardProps {
|
||||
@@ -5,10 +8,12 @@ interface PatientSummaryCardProps {
|
||||
}
|
||||
|
||||
export function PatientSummaryCard({ patient }: PatientSummaryCardProps) {
|
||||
const t = useTranslations('patients');
|
||||
|
||||
if (!patient) {
|
||||
return (
|
||||
<div className="surface-card p-4">
|
||||
<p className="text-sm text-text-muted">Select a patient to view details.</p>
|
||||
<p className="text-sm text-text-muted">{t('selectPatient')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -18,10 +23,15 @@ export function PatientSummaryCard({ patient }: PatientSummaryCardProps) {
|
||||
<h2 className="text-lg font-semibold text-text-primary">
|
||||
{patient.firstName} {patient.lastName}
|
||||
</h2>
|
||||
<p className="text-sm text-text-secondary">Phone: {patient.phone || '-'}</p>
|
||||
<p className="text-sm text-text-secondary">Email: {patient.email || '-'}</p>
|
||||
<p className="text-sm text-text-secondary">
|
||||
Status: {patient.isActive ? 'Active' : 'Inactive'}
|
||||
{t('phoneLabel')} {patient.phone || t('emptyValue')}
|
||||
</p>
|
||||
<p className="text-sm text-text-secondary">
|
||||
{t('emailLabel')} {patient.email || t('emptyValue')}
|
||||
</p>
|
||||
<p className="text-sm text-text-secondary">
|
||||
{t('statusLabel')}{' '}
|
||||
{patient.isActive ? t('statusActive') : t('statusInactive')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
type DialogCloseButtonProps = {
|
||||
@@ -8,12 +9,14 @@ type DialogCloseButtonProps = {
|
||||
};
|
||||
|
||||
export function DialogCloseButton({ onClick, className = '' }: DialogCloseButtonProps) {
|
||||
const t = useTranslations('common');
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`shrink-0 rounded-[var(--radius-sm)] p-1.5 text-text-muted hover:text-text-primary hover:bg-background-secondary/80 focus:outline-none focus:ring-2 focus:ring-primary/35 ${className}`}
|
||||
aria-label="Close"
|
||||
aria-label={t('close')}
|
||||
>
|
||||
<X className="h-5 w-5 icon-flat" />
|
||||
</button>
|
||||
|
||||
94
frontend/src/components/ui/shared/LanguageToggle.tsx
Normal file
94
frontend/src/components/ui/shared/LanguageToggle.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Globe, Check } from 'lucide-react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { usePathname, useRouter } from '@/i18n/navigation';
|
||||
import { useAuth } from '@/lib/hooks/useAuth';
|
||||
import { authApi } from '@/lib/api/auth';
|
||||
import { locales, type AppLocale } from '@/i18n/routing';
|
||||
|
||||
const LOCALE_OPTIONS: AppLocale[] = [...locales];
|
||||
|
||||
export function LanguageToggle() {
|
||||
const t = useTranslations('language');
|
||||
const locale = useLocale() as AppLocale;
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { user, setUserLanguage } = useAuth();
|
||||
const [open, setOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const onDocClick = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', onDocClick);
|
||||
return () => document.removeEventListener('mousedown', onDocClick);
|
||||
}, []);
|
||||
|
||||
const switchLocale = useCallback(
|
||||
async (next: AppLocale) => {
|
||||
if (next === locale) {
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (user) {
|
||||
setUserLanguage(next);
|
||||
try {
|
||||
await authApi.updateLanguage(next);
|
||||
} catch {
|
||||
/* keep optimistic locale in client state */
|
||||
}
|
||||
}
|
||||
|
||||
router.replace(pathname, { locale: next });
|
||||
setOpen(false);
|
||||
},
|
||||
[locale, pathname, router, setUserLanguage, user],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative" ref={menuRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
className="inline-flex h-9 shrink-0 items-center justify-center gap-1.5 rounded-[var(--radius-md)] border border-border/60 bg-background-secondary/80 px-2.5 text-text-primary hover:border-border-strong hover:bg-background-card/80 transition-colors"
|
||||
aria-label={t('selectLanguage')}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="listbox"
|
||||
title={t('label')}
|
||||
>
|
||||
<Globe className="h-[18px] w-[18px] icon-flat" />
|
||||
<span className="text-xs font-medium uppercase">{locale}</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<ul
|
||||
role="listbox"
|
||||
aria-label={t('selectLanguage')}
|
||||
className="absolute right-0 z-[200] mt-2 min-w-[10rem] rounded-[var(--radius-md)] border border-border bg-background-secondary/95 py-1 shadow-lg backdrop-blur-sm"
|
||||
>
|
||||
{LOCALE_OPTIONS.map((option) => {
|
||||
const selected = option === locale;
|
||||
return (
|
||||
<li key={option} role="option" aria-selected={selected}>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between gap-3 px-3 py-2 text-sm text-text-primary hover:bg-background-card/70"
|
||||
onClick={() => void switchLocale(option)}
|
||||
>
|
||||
<span>{t(option)}</span>
|
||||
{selected && <Check className="h-4 w-4 text-primary shrink-0" />}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
// src/components/ui/OrganizationCard.tsx
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import type { Organization } from '@/types/organization';
|
||||
import { organizationTypeIcon } from '@/components/shared/organizationTypeIcon';
|
||||
@@ -13,8 +15,11 @@ export const OrganizationCard: React.FC<OrganizationCardProps> = ({
|
||||
organization,
|
||||
onSelect,
|
||||
}) => {
|
||||
const t = useTranslations('organizations');
|
||||
const tAuth = useTranslations('auth');
|
||||
const Icon = organizationTypeIcon(organization.type);
|
||||
const typeText = organization.type === 'CLINIC' ? 'Dental Clinic' : 'Dental Lab';
|
||||
const typeText =
|
||||
organization.type === 'CLINIC' ? tAuth('dentalClinic') : tAuth('dentalLab');
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -29,11 +34,14 @@ export const OrganizationCard: React.FC<OrganizationCardProps> = ({
|
||||
<p className="text-sm text-text-secondary">{typeText}</p>
|
||||
{organization.plan && (
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
Plan: {organization.plan.name} • {organization.plan.maxUsers} users
|
||||
{t('planLabel', {
|
||||
name: organization.plan.name,
|
||||
maxUsers: organization.plan.maxUsers,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<ChevronRight className="h-5 w-5 icon-flat text-text-muted group-hover:text-primary transition-colors" />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useId, useRef, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { addCalendarDays, startOfLocalDay } from '@/components/appointments/appointmentTime';
|
||||
|
||||
@@ -10,19 +11,19 @@ interface ScheduleDayPickerProps {
|
||||
label?: string;
|
||||
}
|
||||
|
||||
const MONTH_LABELS = [
|
||||
'January',
|
||||
'February',
|
||||
'March',
|
||||
'April',
|
||||
'May',
|
||||
'June',
|
||||
'July',
|
||||
'August',
|
||||
'September',
|
||||
'October',
|
||||
'November',
|
||||
'December',
|
||||
const MONTH_KEYS = [
|
||||
'monthJanuary',
|
||||
'monthFebruary',
|
||||
'monthMarch',
|
||||
'monthApril',
|
||||
'monthMay',
|
||||
'monthJune',
|
||||
'monthJuly',
|
||||
'monthAugust',
|
||||
'monthSeptember',
|
||||
'monthOctober',
|
||||
'monthNovember',
|
||||
'monthDecember',
|
||||
] as const;
|
||||
|
||||
function daysInMonth(year: number, month: number): number {
|
||||
@@ -55,12 +56,14 @@ const selectClassName = `
|
||||
* Calendar day navigator (arrows + year/month/day panel).
|
||||
* Does not restrict past dates — parent pages enforce read-only vs editable for schedule grids/forms.
|
||||
*/
|
||||
export function ScheduleDayPicker({ value, onChange, label = 'Schedule date' }: ScheduleDayPickerProps) {
|
||||
export function ScheduleDayPicker({ value, onChange, label }: ScheduleDayPickerProps) {
|
||||
const t = useTranslations('schedule');
|
||||
const panelId = useId();
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const [panelOpen, setPanelOpen] = useState(false);
|
||||
|
||||
const normalizedValue = startOfLocalDay(value);
|
||||
const resolvedLabel = label ?? t('defaultLabel');
|
||||
|
||||
const labelText = normalizedValue.toLocaleDateString(undefined, {
|
||||
weekday: 'short',
|
||||
@@ -108,13 +111,13 @@ export function ScheduleDayPicker({ value, onChange, label = 'Schedule date' }:
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="relative w-full max-w-md">
|
||||
<p className="text-sm font-medium text-text-secondary mb-2">{label}</p>
|
||||
<p className="text-sm font-medium text-text-secondary mb-2">{resolvedLabel}</p>
|
||||
<div className="flex items-center gap-1 rounded-[var(--radius-md)] border border-border bg-background-secondary/90 px-1 py-1 shadow-[inset_0_1px_0_rgba(255,255,255,0.02)]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(addCalendarDays(normalizedValue, -1))}
|
||||
className="shrink-0 rounded-[var(--radius-sm)] p-2 text-text-muted hover:text-text-primary hover:bg-background-card/80 focus:outline-none focus:ring-2 focus:ring-primary/35"
|
||||
aria-label="Previous day"
|
||||
aria-label={t('previousDay')}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 icon-flat" />
|
||||
</button>
|
||||
@@ -138,7 +141,7 @@ export function ScheduleDayPicker({ value, onChange, label = 'Schedule date' }:
|
||||
type="button"
|
||||
onClick={() => onChange(addCalendarDays(normalizedValue, 1))}
|
||||
className="shrink-0 rounded-[var(--radius-sm)] p-2 text-text-muted hover:text-text-primary hover:bg-background-card/80 focus:outline-none focus:ring-2 focus:ring-primary/35"
|
||||
aria-label="Next day"
|
||||
aria-label={t('nextDay')}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4 icon-flat" />
|
||||
</button>
|
||||
@@ -148,7 +151,7 @@ export function ScheduleDayPicker({ value, onChange, label = 'Schedule date' }:
|
||||
<div
|
||||
id={panelId}
|
||||
role="dialog"
|
||||
aria-label="Choose schedule date"
|
||||
aria-label={t('chooseDate')}
|
||||
className="absolute left-0 right-0 top-full z-50 mt-2 rounded-[var(--radius-md)] border border-border bg-background-secondary p-3 shadow-lg"
|
||||
>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
@@ -157,7 +160,7 @@ export function ScheduleDayPicker({ value, onChange, label = 'Schedule date' }:
|
||||
htmlFor={`${panelId}-year`}
|
||||
className="mb-1 block text-xs font-medium text-text-muted"
|
||||
>
|
||||
Year
|
||||
{t('year')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
@@ -186,7 +189,7 @@ export function ScheduleDayPicker({ value, onChange, label = 'Schedule date' }:
|
||||
htmlFor={`${panelId}-month`}
|
||||
className="mb-1 block text-xs font-medium text-text-muted"
|
||||
>
|
||||
Month
|
||||
{t('month')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
@@ -197,9 +200,9 @@ export function ScheduleDayPicker({ value, onChange, label = 'Schedule date' }:
|
||||
}
|
||||
className={selectClassName}
|
||||
>
|
||||
{MONTH_LABELS.map((name, index) => (
|
||||
<option key={name} value={index}>
|
||||
{name}
|
||||
{MONTH_KEYS.map((key, index) => (
|
||||
<option key={key} value={index}>
|
||||
{t(key)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -215,7 +218,7 @@ export function ScheduleDayPicker({ value, onChange, label = 'Schedule date' }:
|
||||
htmlFor={`${panelId}-day`}
|
||||
className="mb-1 block text-xs font-medium text-text-muted"
|
||||
>
|
||||
Day
|
||||
{t('day')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Link, usePathname } from '@/i18n/navigation';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Users,
|
||||
@@ -20,56 +20,48 @@ import {
|
||||
organizationTypeIcon,
|
||||
} from '@/components/shared/organizationTypeIcon';
|
||||
|
||||
const menu = [
|
||||
{ name: 'Dashboard', path: '/today', icon: LayoutDashboard, read: 'TAB_TODAY_READ' as const },
|
||||
{ name: 'Staff', path: '/staff', icon: UserCog, read: 'TAB_STAFF_READ' as const },
|
||||
{ name: 'Patients', path: '/patients', icon: Users, read: 'TAB_PATIENTS_READ' as const },
|
||||
{ name: 'Appointment', path: '/appointments', icon: Calendar, read: 'TAB_APPOINTMENTS_READ' as const },
|
||||
{ name: 'Treatment', path: '/treatment', icon: FlaskConical, read: 'TAB_TREATMENT_READ' as const },
|
||||
{ name: 'Billing', path: '/billing', icon: CreditCard, read: 'TAB_BILLING_READ' as const },
|
||||
{ name: 'Reports', path: '/reports', icon: FileText, read: 'TAB_REPORTS_READ' as const },
|
||||
];
|
||||
|
||||
function Sidebar() {
|
||||
const t = useTranslations('nav');
|
||||
const tCommon = useTranslations('common');
|
||||
const pathname = usePathname();
|
||||
const { currentOrganization } = useAuth();
|
||||
const pendingConnectionsCount = usePendingConnectionsCount();
|
||||
const counterpartLabel = currentOrganization?.type === 'LAB' ? 'Clinics' : 'Labs';
|
||||
const organizationsTabIcon = organizationTypeIcon(
|
||||
counterpartOrganizationType(currentOrganization?.type),
|
||||
|
||||
|
||||
const menu = useMemo(
|
||||
() => [
|
||||
{ name: t('dashboard'), path: '/today', icon: LayoutDashboard, read: 'TAB_TODAY_READ' as const },
|
||||
{ name: t('staff'), path: '/staff', icon: UserCog, read: 'TAB_STAFF_READ' as const },
|
||||
{
|
||||
name: currentOrganization?.type === 'LAB' ? t('clinics') : t('labs'),
|
||||
path: '/organizations',
|
||||
icon: organizationTypeIcon(counterpartOrganizationType(currentOrganization?.type)),
|
||||
read: 'TAB_ORGANIZATIONS_READ' as const,
|
||||
},
|
||||
{ name: t('patients'), path: '/patients', icon: Users, read: 'TAB_PATIENTS_READ' as const },
|
||||
{ name: t('appointment'), path: '/appointments', icon: Calendar, read: 'TAB_APPOINTMENTS_READ' as const },
|
||||
{ name: t('treatment'), path: '/treatment', icon: FlaskConical, read: 'TAB_TREATMENT_READ' as const },
|
||||
{ name: t('billing'), path: '/billing', icon: CreditCard, read: 'TAB_BILLING_READ' as const },
|
||||
{ name: t('reports'), path: '/reports', icon: FileText, read: 'TAB_REPORTS_READ' as const },
|
||||
],
|
||||
[currentOrganization?.type, t],
|
||||
);
|
||||
|
||||
const visibleMenu = useMemo(
|
||||
() => {
|
||||
const withCounterpartTab = [
|
||||
menu[0],
|
||||
menu[1],
|
||||
{
|
||||
name: counterpartLabel,
|
||||
path: '/organizations',
|
||||
icon: organizationsTabIcon,
|
||||
read: 'TAB_ORGANIZATIONS_READ' as const,
|
||||
},
|
||||
menu[2],
|
||||
menu[3],
|
||||
menu[4],
|
||||
menu[5],
|
||||
menu[6],
|
||||
];
|
||||
return withCounterpartTab.filter((item) => {
|
||||
() =>
|
||||
menu.filter((item) => {
|
||||
if (item.path === '/appointments') {
|
||||
return canAccessAppointmentsSection(currentOrganization);
|
||||
}
|
||||
return canViewTab(currentOrganization, item.read);
|
||||
});
|
||||
},
|
||||
[counterpartLabel, organizationsTabIcon, currentOrganization],
|
||||
}),
|
||||
[currentOrganization, menu],
|
||||
);
|
||||
|
||||
return (
|
||||
<aside className="w-64 bg-background-secondary/90 border-r border-border text-text-primary flex flex-col">
|
||||
<div className="h-[71px] px-4 flex items-center">
|
||||
<h1 className="text-lg font-medium tracking-tight">DyoLink</h1>
|
||||
<h1 className="text-lg font-medium tracking-tight">{tCommon('appName')}</h1>
|
||||
</div>
|
||||
<div className="mx-4 border-b border-border/70" />
|
||||
|
||||
@@ -82,7 +74,7 @@ function Sidebar() {
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
key={item.path}
|
||||
href={item.path}
|
||||
prefetch
|
||||
className={`flex items-center gap-3 px-3 py-2.5 rounded-[var(--radius-sm)] border transition-colors ${
|
||||
@@ -109,4 +101,4 @@ function Sidebar() {
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(Sidebar);
|
||||
export default memo(Sidebar);
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Moon, Sun } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { applyTheme, getStoredTheme, type ThemeMode } from '@/lib/theme';
|
||||
|
||||
export function ThemeToggle() {
|
||||
const t = useTranslations('theme');
|
||||
const [mode, setMode] = useState<ThemeMode | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -34,8 +36,8 @@ export function ThemeToggle() {
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-[var(--radius-md)] border border-border/60 bg-background-secondary/80 text-text-primary hover:border-border-strong hover:bg-background-card/80 transition-colors"
|
||||
aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
title={isDark ? 'Light mode' : 'Dark mode'}
|
||||
aria-label={isDark ? t('switchToLight') : t('switchToDark')}
|
||||
title={isDark ? t('lightMode') : t('darkMode')}
|
||||
>
|
||||
{isDark ? (
|
||||
<Sun className="h-[18px] w-[18px] icon-flat" />
|
||||
|
||||
13
frontend/src/components/ui/shared/TopBarControls.tsx
Normal file
13
frontend/src/components/ui/shared/TopBarControls.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { LanguageToggle } from '@/components/ui/shared/LanguageToggle';
|
||||
import { ThemeToggle } from '@/components/ui/shared/ThemeToggle';
|
||||
|
||||
export function TopBarControls({ className = '' }: { className?: string }) {
|
||||
return (
|
||||
<div className={`flex items-center gap-3 shrink-0 ${className}`.trim()}>
|
||||
<LanguageToggle />
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { CalendarDays } from 'lucide-react';
|
||||
import { purposeStyle } from '@/components/ui/appointments/appointmentPurposeStyles';
|
||||
import { Card } from '@/components/ui/shared/Card';
|
||||
@@ -7,6 +8,14 @@ import { ScheduleDayPicker } from '@/components/ui/shared/ScheduleDayPicker';
|
||||
import { startOfLocalDay } from '@/components/appointments/appointmentTime';
|
||||
import type { TreatmentAppointment } from '@/types/treatment';
|
||||
|
||||
const TREATMENT_TYPE_KEYS = {
|
||||
consultation: 'typeConsultation',
|
||||
filling: 'typeFilling',
|
||||
endo: 'typeEndo',
|
||||
visit: 'typeVisit',
|
||||
hygiene: 'typeHygiene',
|
||||
} as const;
|
||||
|
||||
interface AppointmentsStripProps {
|
||||
stripHidden: boolean;
|
||||
onToggleStripHidden: () => void;
|
||||
@@ -28,16 +37,18 @@ export function AppointmentsStrip({
|
||||
onSelectAppointment,
|
||||
loading = false,
|
||||
}: AppointmentsStripProps) {
|
||||
const t = useTranslations('treatment');
|
||||
|
||||
if (stripHidden) {
|
||||
return (
|
||||
<Card className="flex items-center justify-between gap-3 flex-wrap" padding="sm">
|
||||
<p className="text-sm text-text-secondary">Appointments are hidden.</p>
|
||||
<p className="text-sm text-text-secondary">{t('hiddenMessage')}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleStripHidden}
|
||||
className="text-sm font-medium text-primary hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 rounded-[var(--radius-sm)]"
|
||||
>
|
||||
Show appointments
|
||||
{t('showAppointments')}
|
||||
</button>
|
||||
</Card>
|
||||
);
|
||||
@@ -48,14 +59,14 @@ export function AppointmentsStrip({
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<CalendarDays className="w-5 h-5 text-text-muted shrink-0 icon-flat" aria-hidden />
|
||||
<h2 className="text-sm font-semibold text-text-primary truncate">My appointments</h2>
|
||||
<h2 className="text-sm font-semibold text-text-primary truncate">{t('appointmentsTitle')}</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleStripHidden}
|
||||
className="text-sm font-medium text-primary hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 rounded-[var(--radius-sm)]"
|
||||
>
|
||||
Hide appointments
|
||||
{t('hideAppointments')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -65,13 +76,13 @@ export function AppointmentsStrip({
|
||||
onChange={(d) => onSelectDay(startOfLocalDay(d))}
|
||||
/>
|
||||
{loading && (
|
||||
<p className="text-sm text-text-muted pb-2">Loading appointments…</p>
|
||||
<p className="text-sm text-text-muted pb-2">{t('loadingAppointments')}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{appointments.length === 0 && (
|
||||
<p className="text-sm text-text-muted">No appointments assigned to you on this day.</p>
|
||||
<p className="text-sm text-text-muted">{t('emptyDay')}</p>
|
||||
)}
|
||||
{appointments.map((a) => {
|
||||
const sel = a.id === selectedAppointmentId;
|
||||
@@ -85,6 +96,7 @@ export function AppointmentsStrip({
|
||||
minute: '2-digit',
|
||||
})}`;
|
||||
const palette = purposeStyle(a.purpose);
|
||||
const purposeKey = TREATMENT_TYPE_KEYS[a.purpose as keyof typeof TREATMENT_TYPE_KEYS];
|
||||
return (
|
||||
<Card
|
||||
as="button"
|
||||
@@ -103,7 +115,9 @@ export function AppointmentsStrip({
|
||||
<p className="text-sm font-medium leading-tight truncate mt-0.5">
|
||||
{a.patientFirstName} {a.patientLastName}
|
||||
</p>
|
||||
<p className="text-[11px] opacity-90 capitalize mt-0.5">{a.purpose}</p>
|
||||
<p className="text-[11px] opacity-90 capitalize mt-0.5">
|
||||
{purposeKey ? t(purposeKey) : a.purpose}
|
||||
</p>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { formatCaseSentLines } from '@/components/treatment/caseSendLabel';
|
||||
import type { LinkedOrganizationOption, PastTreatmentCase, TreatmentCaseDraft } from '@/types/treatment';
|
||||
|
||||
@@ -11,11 +14,12 @@ interface CaseSentLabelProps {
|
||||
}
|
||||
|
||||
export function CaseSentLabel({ treatmentCase, orgs, className = 'text-xs text-text-muted' }: CaseSentLabelProps) {
|
||||
const t = useTranslations('treatment');
|
||||
const lines = formatCaseSentLines(treatmentCase.sends, {
|
||||
organizationIds: treatmentCase.sendToOrganizationIds ?? [],
|
||||
sentAt: treatmentCase.sentAt ?? null,
|
||||
orgs,
|
||||
});
|
||||
}, t);
|
||||
|
||||
if (lines.length === 0) return null;
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useId } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { FDI_LOWER_LEFT_TO_RIGHT, FDI_UPPER_LEFT_TO_RIGHT, getToothShapeKind } from '@/components/treatment/fdiToothMeta';
|
||||
import { ToothGlyph } from '@/components/ui/treatment/ToothGlyph';
|
||||
import type { FdiToothId } from '@/types/treatment';
|
||||
@@ -25,6 +26,7 @@ interface FdiToothChartProps {
|
||||
}
|
||||
|
||||
export function FdiToothChart({ selected, onToggle, disabled }: FdiToothChartProps) {
|
||||
const t = useTranslations('treatment');
|
||||
const uid = useId().replace(/:/g, '');
|
||||
const archPeak = 10;
|
||||
|
||||
@@ -127,7 +129,11 @@ export function FdiToothChart({ selected, onToggle, disabled }: FdiToothChartPro
|
||||
${disabled ? 'opacity-50 cursor-not-allowed' : 'hover:scale-105 active:scale-95'}
|
||||
`}
|
||||
aria-pressed={isSel}
|
||||
aria-label={`FDI tooth ${fdi}${isSel ? ', selected' : ''}`}
|
||||
aria-label={
|
||||
isSel
|
||||
? `${t('toothAria', { fdi })}${t('toothSelectedSuffix')}`
|
||||
: t('toothAria', { fdi })
|
||||
}
|
||||
>
|
||||
<ToothGlyph
|
||||
fdi={fdi}
|
||||
@@ -151,17 +157,17 @@ export function FdiToothChart({ selected, onToggle, disabled }: FdiToothChartPro
|
||||
<div className="surface-card p-3 space-y-3">
|
||||
<div className="flex flex-col gap-1.5 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-text-primary">FDI tooth chart</h3>
|
||||
<h3 className="text-sm font-semibold text-text-primary">{t('toothChartTitle')}</h3>
|
||||
<p className="text-[11px] text-text-muted mt-0.5">
|
||||
Tap teeth to multi-select. Applies to the active case.
|
||||
{t('toothChartHint')}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-[11px] text-text-secondary tabular-nums sm:text-right">
|
||||
Selected: {selected.size === 0 ? '—' : [...selected].sort().join(', ')}
|
||||
{t('selectedLabel')} {selected.size === 0 ? t('selectedEmpty') : [...selected].sort().join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] uppercase tracking-wide text-text-muted mb-1 text-center">Upper arch</p>
|
||||
<p className="text-[11px] uppercase tracking-wide text-text-muted mb-1 text-center">{t('upperArch')}</p>
|
||||
|
||||
<div className="overflow-x-auto py-1 -mx-1 px-1">
|
||||
<div className="relative isolate min-w-max mx-auto w-fit">
|
||||
@@ -215,7 +221,7 @@ export function FdiToothChart({ selected, onToggle, disabled }: FdiToothChartPro
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] uppercase tracking-wide text-text-muted mt-1 text-center">Lower arch</p>
|
||||
<p className="text-[11px] uppercase tracking-wide text-text-muted mt-1 text-center">{t('lowerArch')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { FileText } from 'lucide-react';
|
||||
import type { PastTreatment } from '@/types/treatment';
|
||||
import { CaseSentLabel } from '@/components/ui/treatment/CaseSentLabel';
|
||||
|
||||
const TREATMENT_TYPE_KEYS = {
|
||||
consultation: 'typeConsultation',
|
||||
filling: 'typeFilling',
|
||||
endo: 'typeEndo',
|
||||
visit: 'typeVisit',
|
||||
hygiene: 'typeHygiene',
|
||||
} as const;
|
||||
|
||||
interface PastTreatmentsPanelProps {
|
||||
items: PastTreatment[];
|
||||
loading?: boolean;
|
||||
@@ -15,43 +24,50 @@ export function PastTreatmentsPanel({
|
||||
loading,
|
||||
onReviewTreatment,
|
||||
}: PastTreatmentsPanelProps) {
|
||||
const t = useTranslations('treatment');
|
||||
const tCommon = useTranslations('common');
|
||||
|
||||
return (
|
||||
<div className="surface-card p-4 space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-text-primary">Previous treatments</h3>
|
||||
<h3 className="text-sm font-semibold text-text-primary">{t('historyTitle')}</h3>
|
||||
<p className="text-[11px] text-text-muted mt-0.5">
|
||||
Completed treatments for this patient. Each case is listed separately.
|
||||
{t('historySubtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{loading && <p className="text-sm text-text-muted">Loading history…</p>}
|
||||
{loading && <p className="text-sm text-text-muted">{t('loadingHistory')}</p>}
|
||||
|
||||
{!loading && items.length === 0 && (
|
||||
<p className="text-sm text-text-muted">No prior treatments for this patient.</p>
|
||||
<p className="text-sm text-text-muted">{t('historyEmpty')}</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-3 max-h-[min(420px,50vh)] overflow-y-auto pr-1">
|
||||
{items.map((t) => (
|
||||
{items.map((treatment) => (
|
||||
<article
|
||||
key={t.id}
|
||||
key={treatment.id}
|
||||
className="border border-border/70 rounded-[var(--radius-md)] p-2.5 bg-background-secondary/40 space-y-2"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-text-primary truncate">{t.title}</p>
|
||||
<p className="text-[11px] text-text-secondary capitalize mt-0.5">Status: {t.status}</p>
|
||||
<p className="text-sm font-medium text-text-primary truncate">{treatment.title}</p>
|
||||
<p className="text-[11px] text-text-secondary capitalize mt-0.5">
|
||||
{t('statusLabel')} {treatment.status}
|
||||
</p>
|
||||
</div>
|
||||
<time
|
||||
className="text-[11px] text-text-muted tabular-nums shrink-0"
|
||||
dateTime={t.treatmentAt}
|
||||
dateTime={treatment.treatmentAt}
|
||||
>
|
||||
{new Date(t.treatmentAt).toLocaleDateString()}
|
||||
{new Date(treatment.treatmentAt).toLocaleDateString()}
|
||||
</time>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
{t.cases.map((c, idx) => {
|
||||
{treatment.cases.map((c, idx) => {
|
||||
const attachments = c.attachmentMetas ?? [];
|
||||
const typeKey = TREATMENT_TYPE_KEYS[c.treatmentType as keyof typeof TREATMENT_TYPE_KEYS];
|
||||
const typeLabel = typeKey ? t(typeKey) : c.treatmentType;
|
||||
return (
|
||||
<div
|
||||
key={c.id}
|
||||
@@ -59,7 +75,7 @@ export function PastTreatmentsPanel({
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-xs font-medium text-text-primary capitalize">
|
||||
Case {idx + 1} · {c.treatmentType}
|
||||
{t('historyCaseLabel', { n: idx + 1, type: typeLabel })}
|
||||
</p>
|
||||
{c.sentAt && (
|
||||
<CaseSentLabel
|
||||
@@ -69,17 +85,17 @@ export function PastTreatmentsPanel({
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] text-text-secondary">
|
||||
Teeth: {c.teeth.length ? [...c.teeth].sort().join(', ') : 'None selected'}
|
||||
{t('teethLabel')} {c.teeth.length ? [...c.teeth].sort().join(', ') : t('teethNone')}
|
||||
</p>
|
||||
{c.notes?.trim() && (
|
||||
<p className="text-[11px] text-text-muted line-clamp-2">{c.notes}</p>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-[10px] uppercase tracking-wide text-text-muted mb-1">
|
||||
Attachments
|
||||
{t('attachments')}
|
||||
</p>
|
||||
{attachments.length === 0 ? (
|
||||
<p className="text-[11px] text-text-muted">None</p>
|
||||
<p className="text-[11px] text-text-muted">{tCommon('none')}</p>
|
||||
) : (
|
||||
<ul className="space-y-0.5">
|
||||
{attachments.map((doc) => (
|
||||
@@ -106,10 +122,10 @@ export function PastTreatmentsPanel({
|
||||
<div className="pt-1 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onReviewTreatment(t)}
|
||||
onClick={() => onReviewTreatment(treatment)}
|
||||
className="text-xs text-primary hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/35 rounded-[var(--radius-sm)] px-1"
|
||||
>
|
||||
Review details
|
||||
{t('reviewDetails')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useRef } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Button } from '@/components/ui/shared/Button';
|
||||
import { Checkbox } from '@/components/ui/shared/Checkbox';
|
||||
import { Dropdown } from '@/components/ui/shared/Dropdown';
|
||||
@@ -54,6 +55,8 @@ export function TreatmentCasesEditor({
|
||||
onSendCase,
|
||||
onUploadFiles,
|
||||
}: TreatmentCasesEditorProps) {
|
||||
const t = useTranslations('treatment');
|
||||
const tCommon = useTranslations('common');
|
||||
const attachmentInputRef = useRef<HTMLInputElement>(null);
|
||||
const activeCase = cases.find((c) => c.clientId === activeCaseId) ?? cases[0];
|
||||
const activeLinkedOrganizations = orgs.filter((o) => o.active);
|
||||
@@ -84,17 +87,17 @@ export function TreatmentCasesEditor({
|
||||
<div className="surface-card p-4 space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-text-primary">Treatment cases</h3>
|
||||
<h3 className="text-sm font-semibold text-text-primary">{t('casesTitle')}</h3>
|
||||
<p className="text-xs text-text-muted mt-0.5">
|
||||
Each case has its own teeth, notes, attachments, and destinations for send.
|
||||
{t('casesSubtitle')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button type="button" variant="secondary" disabled={!canEdit || disabled} onClick={onPreview}>
|
||||
Preview
|
||||
{tCommon('preview')}
|
||||
</Button>
|
||||
<Button type="button" variant="primary" disabled={!canEdit || disabled} onClick={onAddCase}>
|
||||
Add case
|
||||
{t('addCase')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -105,7 +108,7 @@ export function TreatmentCasesEditor({
|
||||
organizationIds: c.sendToOrganizationIds ?? [],
|
||||
sentAt: c.sentAt ?? null,
|
||||
orgs,
|
||||
});
|
||||
}, t);
|
||||
return (
|
||||
<button
|
||||
key={c.clientId}
|
||||
@@ -121,7 +124,7 @@ export function TreatmentCasesEditor({
|
||||
}
|
||||
`}
|
||||
>
|
||||
Case {idx + 1}
|
||||
{t('caseLabel', { n: idx + 1 })}
|
||||
{sentSummary ? ` · ${sentSummary}` : ''}
|
||||
</button>
|
||||
);
|
||||
@@ -130,7 +133,7 @@ export function TreatmentCasesEditor({
|
||||
|
||||
<div className="space-y-4 border border-border/60 rounded-[var(--radius-md)] p-4 bg-background-secondary/30">
|
||||
<label className="block text-xs font-medium text-text-secondary">
|
||||
Comments
|
||||
{t('comments')}
|
||||
<textarea
|
||||
value={activeCase.comment}
|
||||
onChange={(e) => {
|
||||
@@ -139,7 +142,7 @@ export function TreatmentCasesEditor({
|
||||
cases.map((c) => (c.clientId === activeCaseId ? { ...c, comment: v } : c)),
|
||||
);
|
||||
}}
|
||||
placeholder="Write clinical notes for this case…"
|
||||
placeholder={t('commentsPlaceholder')}
|
||||
rows={5}
|
||||
disabled={disabled || Boolean(activeCase.sentAt)}
|
||||
className="mt-1.5 w-full rounded-[var(--radius-md)] border border-border bg-background-secondary/90 text-text-primary text-sm px-3 py-2 placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/35 resize-y min-h-[120px]"
|
||||
@@ -148,7 +151,7 @@ export function TreatmentCasesEditor({
|
||||
|
||||
<div>
|
||||
<Dropdown
|
||||
label="Treatment type"
|
||||
label={t('treatmentType')}
|
||||
value={activeCase.treatmentType}
|
||||
onChange={(e) => {
|
||||
const nextType = e.target.value as TreatmentCaseDraft['treatmentType'];
|
||||
@@ -162,16 +165,16 @@ export function TreatmentCasesEditor({
|
||||
className="capitalize"
|
||||
style={{ color: treatmentTypeTextColor }}
|
||||
>
|
||||
<option value="consultation" style={{ color: '#ddd6fe', backgroundColor: '#14253d' }} className="capitalize">consultation</option>
|
||||
<option value="filling" style={{ color: '#fed7aa', backgroundColor: '#14253d' }} className="capitalize">filling</option>
|
||||
<option value="endo" style={{ color: '#fecaca', backgroundColor: '#14253d' }} className="capitalize">endo</option>
|
||||
<option value="visit" style={{ color: '#bae6fd', backgroundColor: '#14253d' }} className="capitalize">visit</option>
|
||||
<option value="hygiene" style={{ color: '#d9f99d', backgroundColor: '#14253d' }} className="capitalize">hygiene</option>
|
||||
<option value="consultation" style={{ color: '#ddd6fe', backgroundColor: '#14253d' }} className="capitalize">{t('typeConsultation')}</option>
|
||||
<option value="filling" style={{ color: '#fed7aa', backgroundColor: '#14253d' }} className="capitalize">{t('typeFilling')}</option>
|
||||
<option value="endo" style={{ color: '#fecaca', backgroundColor: '#14253d' }} className="capitalize">{t('typeEndo')}</option>
|
||||
<option value="visit" style={{ color: '#bae6fd', backgroundColor: '#14253d' }} className="capitalize">{t('typeVisit')}</option>
|
||||
<option value="hygiene" style={{ color: '#d9f99d', backgroundColor: '#14253d' }} className="capitalize">{t('typeHygiene')}</option>
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs font-medium text-text-secondary mb-2">Attachments</p>
|
||||
<p className="text-xs font-medium text-text-secondary mb-2">{t('attachments')}</p>
|
||||
<input
|
||||
ref={attachmentInputRef}
|
||||
id="treatment-case-attachments"
|
||||
@@ -183,7 +186,7 @@ export function TreatmentCasesEditor({
|
||||
e.target.value = '';
|
||||
}}
|
||||
className="sr-only"
|
||||
aria-label="Attach files for this treatment case"
|
||||
aria-label={t('attachFiles')}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -193,7 +196,7 @@ export function TreatmentCasesEditor({
|
||||
onClick={() => attachmentInputRef.current?.click()}
|
||||
aria-controls="treatment-case-attachments"
|
||||
>
|
||||
Choose files
|
||||
{t('chooseFiles')}
|
||||
</Button>
|
||||
{activeCase.attachmentMetas.length > 0 && (
|
||||
<ul className="mt-2 space-y-1 text-xs text-text-muted">
|
||||
@@ -208,17 +211,17 @@ export function TreatmentCasesEditor({
|
||||
|
||||
<div>
|
||||
<p className="text-xs font-medium text-text-secondary mb-2">
|
||||
Send this case to linked organizations
|
||||
{t('sendToOrgs')}
|
||||
</p>
|
||||
<div className="space-y-2 mb-2">
|
||||
<SearchBar
|
||||
value={organizationSearch}
|
||||
onChange={onOrganizationSearchChange}
|
||||
placeholder="Search active organizations..."
|
||||
placeholder={t('searchOrgsPlaceholder')}
|
||||
/>
|
||||
{recentOrganizations.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs text-text-muted">Recent:</span>
|
||||
<span className="text-xs text-text-muted">{t('recent')}</span>
|
||||
{recentOrganizations.map((o) => (
|
||||
<button
|
||||
key={o.id}
|
||||
@@ -254,7 +257,7 @@ export function TreatmentCasesEditor({
|
||||
/>
|
||||
))}
|
||||
{filteredOrganizations.length === 0 && (
|
||||
<p className="text-xs text-text-muted">No active organization matches your search.</p>
|
||||
<p className="text-xs text-text-muted">{t('noOrgMatch')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -267,7 +270,7 @@ export function TreatmentCasesEditor({
|
||||
isLoading={sendBusyId === activeCase.clientId}
|
||||
onClick={() => onSendCase(activeCase)}
|
||||
>
|
||||
Send this case
|
||||
{t('sendThisCase')}
|
||||
</Button>
|
||||
{activeCase.sentAt && (
|
||||
<CaseSentLabel treatmentCase={activeCase} orgs={orgs} />
|
||||
@@ -284,10 +287,10 @@ export function TreatmentCasesEditor({
|
||||
isLoading={saveBusy}
|
||||
onClick={onSave}
|
||||
>
|
||||
Save treatment draft
|
||||
{t('saveDraft')}
|
||||
</Button>
|
||||
<p className="text-xs text-text-muted self-center">
|
||||
{isDirty ? 'Unsaved changes' : 'Draft saved'}. Sending is per case and saves first automatically.
|
||||
{isDirty ? t('unsavedChanges') : t('draftSaved')}. {t('sendSavesFirst')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { FileText } from 'lucide-react';
|
||||
import { treatmentsApi } from '@/lib/api/treatments';
|
||||
import type { TreatmentAttachmentMeta } from '@/types/treatment';
|
||||
@@ -22,6 +23,7 @@ export function TreatmentLatestAttachmentPreview({
|
||||
attachment,
|
||||
className = '',
|
||||
}: TreatmentLatestAttachmentPreviewProps) {
|
||||
const tCommon = useTranslations('common');
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [loadFailed, setLoadFailed] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -72,11 +74,11 @@ export function TreatmentLatestAttachmentPreview({
|
||||
>
|
||||
{!attachment ? (
|
||||
<div className="flex h-full w-full items-center justify-center text-[10px] text-text-muted">
|
||||
None
|
||||
{tCommon('none')}
|
||||
</div>
|
||||
) : loading ? (
|
||||
<div className="flex h-full w-full items-center justify-center text-[10px] text-text-muted">
|
||||
…
|
||||
{tCommon('loadingEllipsis')}
|
||||
</div>
|
||||
) : loadFailed || !canRenderPreview || !previewUrl ? (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-1 p-1.5 text-center">
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Button } from '@/components/ui/shared/Button';
|
||||
import type { PastTreatment } from '@/types/treatment';
|
||||
|
||||
const TREATMENT_TYPE_KEYS = {
|
||||
consultation: 'typeConsultation',
|
||||
filling: 'typeFilling',
|
||||
endo: 'typeEndo',
|
||||
visit: 'typeVisit',
|
||||
hygiene: 'typeHygiene',
|
||||
} as const;
|
||||
|
||||
interface TreatmentPreviewCardProps {
|
||||
draft: PastTreatment | null;
|
||||
disabled?: boolean;
|
||||
@@ -10,16 +19,22 @@ interface TreatmentPreviewCardProps {
|
||||
}
|
||||
|
||||
export function TreatmentPreviewCard({ draft, disabled, onPreview }: TreatmentPreviewCardProps) {
|
||||
const t = useTranslations('treatment');
|
||||
|
||||
const attachmentCount = draft
|
||||
? draft.cases.reduce((n, c) => n + (c.attachmentMetas?.length ?? 0), 0)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="surface-card p-4 space-y-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-semibold text-text-primary">Treatment preview</h3>
|
||||
<h3 className="text-sm font-semibold text-text-primary">{t('previewTitle')}</h3>
|
||||
<Button type="button" variant="primary" disabled={disabled || !draft} onClick={onPreview}>
|
||||
Preview current draft
|
||||
{t('previewDraft')}
|
||||
</Button>
|
||||
</div>
|
||||
{!draft ? (
|
||||
<p className="text-sm text-text-muted">Select an appointment to preview its draft.</p>
|
||||
<p className="text-sm text-text-muted">{t('selectAppointment')}</p>
|
||||
) : (
|
||||
<div className="border border-border/70 rounded-[var(--radius-md)] p-3 bg-background-secondary/40 space-y-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
@@ -27,26 +42,31 @@ export function TreatmentPreviewCard({ draft, disabled, onPreview }: TreatmentPr
|
||||
<span className="text-xs text-text-muted tabular-nums shrink-0 capitalize">{draft.status}</span>
|
||||
</div>
|
||||
<p className="text-xs text-text-secondary">
|
||||
{draft.cases.length} case{draft.cases.length === 1 ? '' : 's'} ·{' '}
|
||||
{draft.cases.reduce((n, c) => n + (c.attachmentMetas?.length ?? 0), 0)} attachment
|
||||
{draft.cases.reduce((n, c) => n + (c.attachmentMetas?.length ?? 0), 0) === 1 ? '' : 's'}
|
||||
{t('caseCount', { n: draft.cases.length })} ·{' '}
|
||||
{t('attachmentCount', { n: attachmentCount })}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{draft.cases.slice(0, 2).map((c, idx) => (
|
||||
{draft.cases.slice(0, 2).map((c, idx) => {
|
||||
const typeKey = TREATMENT_TYPE_KEYS[c.treatmentType as keyof typeof TREATMENT_TYPE_KEYS];
|
||||
const typeLabel = typeKey ? t(typeKey) : c.treatmentType;
|
||||
return (
|
||||
<div
|
||||
key={c.id}
|
||||
className="rounded-[var(--radius-sm)] border border-border/60 px-2.5 py-2 text-xs text-text-secondary"
|
||||
>
|
||||
<span className="text-text-primary font-medium capitalize">
|
||||
Case {idx + 1}: {c.treatmentType}
|
||||
{t('caseSummary', { n: idx + 1, type: typeLabel })}
|
||||
</span>
|
||||
{c.teeth.length > 0 && (
|
||||
<span className="ml-1 tabular-nums">· Teeth {[...c.teeth].sort().join(', ')}</span>
|
||||
<span className="ml-1 tabular-nums">
|
||||
{t('teethPrefix')} {[...c.teeth].sort().join(', ')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
{draft.cases.length > 2 && (
|
||||
<p className="text-xs text-text-muted">+ {draft.cases.length - 2} more case(s)</p>
|
||||
<p className="text-xs text-text-muted">{t('moreCases', { n: draft.cases.length - 2 })}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Loader2, Paperclip, Send } from 'lucide-react';
|
||||
import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
|
||||
import { Button } from '@/components/ui/shared/Button';
|
||||
@@ -32,6 +33,14 @@ function caseKey(c: PastTreatmentCase): string {
|
||||
const caseActionIconClass =
|
||||
'inline-flex items-center justify-center rounded-[var(--radius-sm)] p-1.5 text-text-secondary transition-colors hover:bg-background-card/80 hover:text-text-primary disabled:cursor-not-allowed disabled:opacity-40';
|
||||
|
||||
const TREATMENT_TYPE_KEYS = {
|
||||
consultation: 'typeConsultation',
|
||||
filling: 'typeFilling',
|
||||
endo: 'typeEndo',
|
||||
visit: 'typeVisit',
|
||||
hygiene: 'typeHygiene',
|
||||
} as const;
|
||||
|
||||
export function TreatmentPreviewDialog({
|
||||
open,
|
||||
onClose,
|
||||
@@ -45,6 +54,7 @@ export function TreatmentPreviewDialog({
|
||||
getCaseOrgIds,
|
||||
onToggleCaseOrg,
|
||||
}: TreatmentPreviewDialogProps) {
|
||||
const t = useTranslations('treatment');
|
||||
const [expandedSendCaseId, setExpandedSendCaseId] = useState<string | null>(null);
|
||||
const fileInputsRef = useRef<Record<string, HTMLInputElement | null>>({});
|
||||
|
||||
@@ -64,10 +74,10 @@ export function TreatmentPreviewDialog({
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h2 id="treatment-preview-title" className="text-lg font-semibold text-text-primary pr-2">
|
||||
Treatment preview
|
||||
{t('previewDialogTitle')}
|
||||
</h2>
|
||||
<p className="text-xs text-text-muted mt-0.5">
|
||||
Review cases, attachments, and send destinations.
|
||||
{t('previewDialogSubtitle')}
|
||||
</p>
|
||||
</div>
|
||||
<DialogCloseButton onClick={onClose} />
|
||||
@@ -80,10 +90,12 @@ export function TreatmentPreviewDialog({
|
||||
{new Date(treatment.treatmentAt).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-text-secondary capitalize">Status: {treatment.status}</p>
|
||||
<p className="text-xs text-text-secondary capitalize">
|
||||
{t('statusLabel')} {treatment.status}
|
||||
</p>
|
||||
|
||||
{treatment.cases.length === 0 ? (
|
||||
<p className="text-sm text-text-muted">No cases in this treatment.</p>
|
||||
<p className="text-sm text-text-muted">{t('noCases')}</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{treatment.cases.map((c, idx) => {
|
||||
@@ -98,6 +110,8 @@ export function TreatmentPreviewDialog({
|
||||
const comment = c.notes?.trim() ?? '';
|
||||
const attachBusy = uploadBusyCaseId === key;
|
||||
const sendBusy = sendBusyCaseId === key;
|
||||
const typeKey = TREATMENT_TYPE_KEYS[c.treatmentType as keyof typeof TREATMENT_TYPE_KEYS];
|
||||
const typeLabel = typeKey ? t(typeKey) : c.treatmentType;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -107,7 +121,9 @@ export function TreatmentPreviewDialog({
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-x-4 gap-y-1">
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-xs font-medium text-text-primary">Case {idx + 1}</p>
|
||||
<p className="text-xs font-medium text-text-primary">
|
||||
{t('caseLabel', { n: idx + 1 })}
|
||||
</p>
|
||||
{actionsEnabled && (
|
||||
<div className="flex items-center gap-0.5">
|
||||
<input
|
||||
@@ -129,8 +145,8 @@ export function TreatmentPreviewDialog({
|
||||
type="button"
|
||||
className={caseActionIconClass}
|
||||
disabled={attachBusy}
|
||||
aria-label="Attach files"
|
||||
title="Attach files"
|
||||
aria-label={t('attachFilesShort')}
|
||||
title={t('attachFilesShort')}
|
||||
onClick={() => fileInputsRef.current[key]?.click()}
|
||||
>
|
||||
{attachBusy ? (
|
||||
@@ -145,8 +161,8 @@ export function TreatmentPreviewDialog({
|
||||
sendExpanded ? 'bg-primary-soft text-primary' : ''
|
||||
}`}
|
||||
disabled={sendBusy}
|
||||
aria-label="Send this case"
|
||||
title="Send this case"
|
||||
aria-label={t('sendCase')}
|
||||
title={t('sendCase')}
|
||||
aria-expanded={sendExpanded}
|
||||
onClick={() =>
|
||||
setExpandedSendCaseId((prev) => (prev === key ? null : key))
|
||||
@@ -162,18 +178,18 @@ export function TreatmentPreviewDialog({
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] text-text-secondary capitalize">
|
||||
Type: {c.treatmentType}
|
||||
{t('typeLabel')} {typeLabel}
|
||||
</p>
|
||||
<p className="text-[11px] text-text-secondary">
|
||||
Teeth:{' '}
|
||||
{c.teeth.length ? [...c.teeth].sort().join(', ') : 'None selected'}
|
||||
{t('teethLabel')}{' '}
|
||||
{c.teeth.length ? [...c.teeth].sort().join(', ') : t('teethNone')}
|
||||
</p>
|
||||
{comment ? (
|
||||
<p className="text-[11px] text-text-muted line-clamp-2" title={comment}>
|
||||
Comments: {comment}
|
||||
{t('commentsLabel')} {comment}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-[11px] text-text-muted">Comments: —</p>
|
||||
<p className="text-[11px] text-text-muted">{t('commentsEmpty')}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -186,7 +202,7 @@ export function TreatmentPreviewDialog({
|
||||
/>
|
||||
)}
|
||||
<p className="text-[10px] uppercase tracking-wide text-text-muted">
|
||||
Attachments
|
||||
{t('attachments')}
|
||||
</p>
|
||||
<TreatmentLatestAttachmentPreview attachment={latestAttachment} />
|
||||
</div>
|
||||
@@ -195,10 +211,10 @@ export function TreatmentPreviewDialog({
|
||||
{sendExpanded && editable && !sent && (
|
||||
<div className="mt-2 space-y-2 border-t border-border/40 pt-2">
|
||||
<p className="text-xs font-medium text-text-secondary">
|
||||
Send to linked organizations
|
||||
{t('sendToLinkedOrgs')}
|
||||
</p>
|
||||
{activeOrgs.length === 0 ? (
|
||||
<p className="text-xs text-text-muted">No active linked organizations.</p>
|
||||
<p className="text-xs text-text-muted">{t('noActiveOrgs')}</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{activeOrgs.map((o) => (
|
||||
@@ -219,7 +235,7 @@ export function TreatmentPreviewDialog({
|
||||
isLoading={sendBusy}
|
||||
onClick={() => void onSend?.(key, selectedOrgIds)}
|
||||
>
|
||||
Confirm send
|
||||
{t('confirmSend')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { AppointmentsStrip } from '@/components/ui/treatment/AppointmentsStrip';
|
||||
import { FdiToothChart } from '@/components/ui/treatment/FdiToothChart';
|
||||
import { PastTreatmentsPanel } from '@/components/ui/treatment/PastTreatmentsPanel';
|
||||
@@ -34,6 +35,14 @@ import type {
|
||||
TreatmentCaseDraft,
|
||||
} from '@/types/treatment';
|
||||
|
||||
const TREATMENT_TYPE_KEYS = {
|
||||
consultation: 'typeConsultation',
|
||||
filling: 'typeFilling',
|
||||
endo: 'typeEndo',
|
||||
visit: 'typeVisit',
|
||||
hygiene: 'typeHygiene',
|
||||
} as const;
|
||||
|
||||
function newCase(): TreatmentCaseDraft {
|
||||
return {
|
||||
clientId:
|
||||
@@ -121,6 +130,7 @@ interface TreatmentWorkspaceProps {
|
||||
}
|
||||
|
||||
export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWorkspaceProps) {
|
||||
const t = useTranslations('treatment');
|
||||
const { showError, showSuccess, messages: toastMessages } = useToast();
|
||||
const canView = canViewTreatment(currentOrganization);
|
||||
const canEdit = canEditTreatment(currentOrganization);
|
||||
@@ -185,12 +195,14 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
const currentDraftPreview = useMemo<PastTreatment | null>(() => {
|
||||
if (!selectedAppointment) return null;
|
||||
return casesToPreviewTreatment(cases, {
|
||||
title: `Draft · ${selectedAppointment.patientFirstName} ${selectedAppointment.patientLastName}`,
|
||||
title: t('draftTitle', {
|
||||
patientName: `${selectedAppointment.patientFirstName} ${selectedAppointment.patientLastName}`,
|
||||
}),
|
||||
patientId: selectedAppointment.patientId,
|
||||
treatmentAt: new Date().toISOString(),
|
||||
status: 'draft',
|
||||
});
|
||||
}, [cases, selectedAppointment]);
|
||||
}, [cases, selectedAppointment, t]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectionLocked(false);
|
||||
@@ -217,7 +229,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (!cancelled) {
|
||||
showError(formatApiErrorMessage(error, 'Failed to load appointments.'));
|
||||
showError(formatApiErrorMessage(error, t('errorLoadAppointments')));
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setApptsLoading(false);
|
||||
@@ -226,7 +238,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [userId, selectedDay, showError]);
|
||||
}, [userId, selectedDay, showError, t]);
|
||||
|
||||
useEffect(() => {
|
||||
const today = startOfLocalDay(new Date());
|
||||
@@ -250,14 +262,14 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
if (!cancelled) setOrgs(list.data);
|
||||
} catch (error: unknown) {
|
||||
if (!cancelled) {
|
||||
showError(formatApiErrorMessage(error, 'Failed to load linked organizations.'));
|
||||
showError(formatApiErrorMessage(error, t('errorLoadOrgs')));
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [showError]);
|
||||
}, [showError, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedAppointment) {
|
||||
@@ -272,7 +284,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
if (!cancelled) setHistory(response.data);
|
||||
} catch (error: unknown) {
|
||||
if (!cancelled) {
|
||||
showError(formatApiErrorMessage(error, 'Failed to load treatment history.'));
|
||||
showError(formatApiErrorMessage(error, t('errorLoadHistory')));
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setHistoryLoading(false);
|
||||
@@ -281,7 +293,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [selectedAppointment?.patientId, showError]);
|
||||
}, [selectedAppointment?.patientId, showError, t]);
|
||||
|
||||
useEffect(() => {
|
||||
const appointmentId = selectedAppointment?.id;
|
||||
@@ -310,19 +322,19 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
setOrganizationSearch('');
|
||||
} catch (error: unknown) {
|
||||
if (!cancelled) {
|
||||
showError(formatApiErrorMessage(error, 'Failed to load treatment draft.'));
|
||||
showError(formatApiErrorMessage(error, t('errorLoadDraft')));
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [selectedAppointment?.id, showError]);
|
||||
}, [selectedAppointment?.id, showError, t]);
|
||||
|
||||
const confirmDiscardIfDirty = useCallback(() => {
|
||||
if (!isDirty) return true;
|
||||
return window.confirm('You have unsaved changes. Discard them and continue?');
|
||||
}, [isDirty]);
|
||||
return window.confirm(t('confirmDiscard'));
|
||||
}, [isDirty, t]);
|
||||
|
||||
const onPickAppointment = useCallback(
|
||||
(id: string) => {
|
||||
@@ -361,16 +373,14 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
: c,
|
||||
),
|
||||
);
|
||||
showSuccess(
|
||||
`${uploaded.data.length} file${uploaded.data.length === 1 ? '' : 's'} uploaded successfully.`,
|
||||
);
|
||||
showSuccess(t('successFilesUploaded', { count: uploaded.data.length }));
|
||||
} catch (error: unknown) {
|
||||
showError(formatApiErrorMessage(error, 'Failed to upload attachments.'));
|
||||
showError(formatApiErrorMessage(error, t('errorUpload')));
|
||||
} finally {
|
||||
setUploadBusyCaseId(null);
|
||||
}
|
||||
},
|
||||
[canEditTreatmentForDay, selectedAppointment, showSuccess, showError],
|
||||
[canEditTreatmentForDay, selectedAppointment, showSuccess, showError, t],
|
||||
);
|
||||
|
||||
const persistDraft = useCallback(async () => {
|
||||
@@ -401,13 +411,13 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
setSaveBusy(true);
|
||||
try {
|
||||
await persistDraft();
|
||||
showSuccess('Treatment draft saved.');
|
||||
showSuccess(t('successDraftSaved'));
|
||||
} catch (error: unknown) {
|
||||
showError(formatApiErrorMessage(error, 'Failed to save treatment draft.'));
|
||||
showError(formatApiErrorMessage(error, t('errorSaveDraft')));
|
||||
} finally {
|
||||
setSaveBusy(false);
|
||||
}
|
||||
}, [canEditTreatmentForDay, selectedAppointment, persistDraft, showSuccess, showError]);
|
||||
}, [canEditTreatmentForDay, selectedAppointment, persistDraft, showSuccess, showError, t]);
|
||||
|
||||
const handleSendCase = useCallback(
|
||||
async (treatmentCase: TreatmentCaseDraft) => {
|
||||
@@ -416,14 +426,14 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
orgs.some((o) => o.id === id && o.active),
|
||||
);
|
||||
if (targets.length === 0) {
|
||||
showError('Choose at least one active organization to send this case.');
|
||||
showError(t('errorChooseOrg'));
|
||||
return;
|
||||
}
|
||||
setSendBusyId(treatmentCase.clientId);
|
||||
try {
|
||||
const saved = await persistDraft();
|
||||
const serverCase = saved.cases.find((c) => c.clientId === treatmentCase.clientId);
|
||||
if (!serverCase?.id) throw new Error('Case must be saved before sending.');
|
||||
if (!serverCase?.id) throw new Error(t('errorCaseMustSave'));
|
||||
|
||||
const response = await treatmentsApi.sendCase(serverCase.id, { organizationIds: targets });
|
||||
setCases((prev) => {
|
||||
@@ -445,14 +455,14 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
const next = [...targets.filter((id) => !prev.includes(id)), ...prev];
|
||||
return next.slice(0, 10);
|
||||
});
|
||||
showSuccess('Case sent to selected organizations.');
|
||||
showSuccess(t('successCaseSent'));
|
||||
} catch (error: unknown) {
|
||||
showError(formatApiErrorMessage(error, 'Failed to send case.'));
|
||||
showError(formatApiErrorMessage(error, t('errorSendCase')));
|
||||
} finally {
|
||||
setSendBusyId(null);
|
||||
}
|
||||
},
|
||||
[canEditTreatmentForDay, selectedAppointment, orgs, persistDraft, showSuccess, showError],
|
||||
[canEditTreatmentForDay, selectedAppointment, orgs, persistDraft, showSuccess, showError, t],
|
||||
);
|
||||
|
||||
const openPreview = useCallback(
|
||||
@@ -489,9 +499,9 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
if (!canView) {
|
||||
return (
|
||||
<div className="surface-card p-6 max-w-xl">
|
||||
<h2 className="text-lg font-semibold text-text-primary">Treatment workspace</h2>
|
||||
<h2 className="text-lg font-semibold text-text-primary">{t('noPermissionTitle')}</h2>
|
||||
<p className="text-sm text-text-secondary mt-2">
|
||||
You do not have permission to view the Treatment tab for this organization.
|
||||
{t('noPermissionBody')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -500,11 +510,9 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<header className="space-y-1">
|
||||
<h1 className="text-2xl font-semibold text-text-primary">Treatment</h1>
|
||||
<h1 className="text-2xl font-semibold text-text-primary">{t('title')}</h1>
|
||||
<p className="text-sm text-text-secondary">
|
||||
{canEdit
|
||||
? 'Document cases for your appointments, save drafts, and send work to linked organizations.'
|
||||
: 'View-only access — you can review appointments and treatment history but cannot edit.'}
|
||||
{canEdit ? t('subtitleEdit') : t('subtitleReadOnly')}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
@@ -523,8 +531,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
|
||||
{isViewingPastDay && (
|
||||
<p className="text-sm text-text-secondary rounded-[var(--radius-md)] border border-border/60 bg-background-secondary/50 px-3 py-2">
|
||||
Past days are view-only. You can review appointments and history, but treatment cases
|
||||
cannot be added or changed.
|
||||
{t('pastDayNotice')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -532,18 +539,20 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
<div className="space-y-3 min-w-0 xl:max-w-[380px]">
|
||||
{selectedAppointment ? (
|
||||
<div className="surface-card p-3 space-y-0.5">
|
||||
<p className="text-[10px] uppercase tracking-wide text-text-muted">Selected patient</p>
|
||||
<p className="text-[10px] uppercase tracking-wide text-text-muted">{t('selectedPatient')}</p>
|
||||
<p className="text-base font-semibold text-text-primary">
|
||||
{selectedAppointment.patientFirstName} {selectedAppointment.patientLastName}
|
||||
</p>
|
||||
<p className="text-[11px] text-text-secondary">
|
||||
Purpose:{' '}
|
||||
<span className="capitalize text-text-primary">{selectedAppointment.purpose}</span>
|
||||
{t('purposeLabel')}{' '}
|
||||
<span className="capitalize text-text-primary">
|
||||
{t(TREATMENT_TYPE_KEYS[selectedAppointment.purpose as keyof typeof TREATMENT_TYPE_KEYS] ?? selectedAppointment.purpose)}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="surface-card p-3 text-sm text-text-muted">
|
||||
{apptsLoading ? 'Loading appointments…' : 'Select a day with at least one appointment.'}
|
||||
{apptsLoading ? t('loadingAppointments') : t('selectDayWithAppointment')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user