Merge branch 'master' into feature/mobile-responsive

This commit is contained in:
2026-07-12 10:02:58 +03:30
55 changed files with 5194 additions and 780 deletions

View File

@@ -160,6 +160,9 @@ export function AppointmentScheduleGrid({
});
return;
}
if (!canBook) {
return;
}
onAppointmentClick?.(apt);
}
@@ -373,6 +376,10 @@ export function AppointmentScheduleGrid({
treatmentCatalog={treatmentCatalog}
anchorRect={overlapPopover.anchorRect}
onSelect={(apt) => {
if (!canBook) {
setOverlapPopover(null);
return;
}
const provider = providers.find((p) => p.userId === apt.providerUserId);
if (
provider &&

View File

@@ -19,7 +19,7 @@ import type { OrgTypeName } from '@/components/shared/permissions';
import { useAuth } from '@/lib/hooks/useAuth';
import { usePendingConnectionsCount } from '@/lib/hooks/usePendingConnectionsCount';
import {
canAccessAppointmentsSection,
canViewAppointmentsTab,
canViewCases,
canViewTasks,
canViewTab,
@@ -79,7 +79,7 @@ function Sidebar({ mobileOpen = false, onClose }: SidebarProps) {
return false;
}
if (item.path === '/appointments') {
return canAccessAppointmentsSection(currentOrganization);
return canViewAppointmentsTab(currentOrganization);
}
if (item.path === '/cases') {
return canViewCases(currentOrganization);

View File

@@ -2,6 +2,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslations } from 'next-intl';
import { useRouter } from '@/i18n/navigation';
import { AppointmentsStrip } from '@/components/ui/treatment/AppointmentsStrip';
import { FdiToothChart } from '@/components/ui/treatment/FdiToothChart';
import { LabCasesDispatchPanel } from '@/components/ui/treatment/LabCasesDispatchPanel';
@@ -254,10 +255,16 @@ function detailsToPreviewTreatment(
interface TreatmentWorkspaceProps {
userId: string;
currentOrganization: Organization | null;
initialAppointmentId?: string | null;
}
export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWorkspaceProps) {
export function TreatmentWorkspace({
userId,
currentOrganization,
initialAppointmentId = null,
}: TreatmentWorkspaceProps) {
const t = useTranslations('treatment');
const router = useRouter();
const { showError, showSuccess, messages: toastMessages } = useToast();
const canView = canViewTreatment(currentOrganization);
const canEdit = canEditTreatment(currentOrganization);
@@ -308,6 +315,15 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
const labCaseDraftsRef = useRef(labCaseDrafts);
labCaseDraftsRef.current = labCaseDrafts;
const skipNextGetDraftRef = useRef(false);
const pendingAppointmentIdRef = useRef<string | null>(initialAppointmentId);
useEffect(() => {
pendingAppointmentIdRef.current = initialAppointmentId;
if (initialAppointmentId) {
setSelectedDay(startOfLocalDay(new Date()));
setSelectionLocked(false);
}
}, [initialAppointmentId]);
const [sendBusyId, setSendBusyId] = useState<string | null>(null);
const [uploadBusyDetailId, setUploadBusyDetailId] = useState<string | null>(null);
@@ -464,7 +480,16 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
.map(mapAppointment);
setAppointments(list);
if (!selectionLockedRef.current) {
setSelectedAppointmentId(pickAutoAppointment(list, selectedDay));
const pendingId = pendingAppointmentIdRef.current;
if (pendingId && list.some((appointment) => appointment.id === pendingId)) {
setSelectedAppointmentId(pendingId);
setSelectionLocked(true);
pendingAppointmentIdRef.current = null;
router.replace('/treatment', { scroll: false });
} else {
pendingAppointmentIdRef.current = null;
setSelectedAppointmentId(pickAutoAppointment(list, selectedDay));
}
}
} catch (error: unknown) {
if (!cancelled) {
@@ -477,7 +502,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
return () => {
cancelled = true;
};
}, [userId, selectedDay, showError, t]);
}, [userId, selectedDay, showError, t, router]);
useEffect(() => {
const today = startOfLocalDay(new Date());

View File

@@ -0,0 +1,102 @@
/**
* Shared pastel palette for treatment types, prosthesis types, and dashboard charts.
* Treatment and prosthesis each have dedicated hex maps — prosthesis colors are unique
* within the prosthesis catalog (no duplicate swatches on charts or badges).
*/
export const TREATMENT_TYPE_COLORS: Record<string, string> = {
restoration: '#fed7aa',
specialized_restoration: '#fdba74',
radiography: '#cbd5e1',
endo: '#fecaca',
surgery: '#fca5a5',
prosthesis: '#c4b5fd',
implant: '#a5b4fc',
orthodontics: '#93c5fd',
perio: '#86efac',
pediatrics: '#fde68a',
extraction: '#f9a8d4',
clinic_visit: '#bae6fd',
continue_treatment: '#99f6e4',
};
/** Dedicated prosthesis palette — one distinct pastel per catalog code. */
export const PROSTHESIS_TYPE_COLORS: Record<string, string> = {
pfm_crown: '#e2e8f0',
pfz_crown: '#bbf7d0',
monolithic_zirconia: '#e0f2fe',
glass_ceramic_crown: '#fef08a',
full_metal_crown: '#d4d4d8',
temporary_resin_crown: '#bae6fd',
pmma: '#7dd3fc',
peek_crown: '#5eead4',
veneer_zirconia: '#6ee7b7',
veneer_ips_press: '#fed7aa',
veneer_ips_cad: '#fdba74',
soft_structure: '#ddd6fe',
customized_abutment: '#a5b4fc',
prefabricated_abutment: '#c7d2fe',
ti_base_abutment: '#bfdbfe',
multi_unit_abutment: '#818cf8',
zirconia_abutment: '#34d399',
screw_retained: '#e9d5ff',
zirconia_overlay: '#2dd4bf',
ips_overlay: '#fef3c7',
smile_design: '#f9a8d4',
mockup: '#fbcfe8',
};
export const CATALOG_FALLBACK_COLORS = [
'#ddd6fe',
'#fed7aa',
'#fecaca',
'#bae6fd',
'#d9f99d',
'#fbcfe8',
] as const;
/** Fallback rotation for unknown prosthesis codes — drawn from the prosthesis palette. */
export const PROSTHESIS_FALLBACK_COLORS: readonly string[] = [
...new Set(Object.values(PROSTHESIS_TYPE_COLORS)),
];
/** Ordered palette for charts and rotating unknown treatment catalog codes. */
export const CATALOG_PALETTE_COLORS: readonly string[] = [
'#fed7aa',
'#fdba74',
'#cbd5e1',
'#fecaca',
'#fca5a5',
'#c4b5fd',
'#a5b4fc',
'#93c5fd',
'#86efac',
'#fde68a',
'#f9a8d4',
'#bae6fd',
'#99f6e4',
'#ddd6fe',
'#d9f99d',
'#fbcfe8',
...PROSTHESIS_FALLBACK_COLORS.filter(
(color) =>
![
'#fed7aa',
'#fdba74',
'#bae6fd',
'#f9a8d4',
'#ddd6fe',
'#fbcfe8',
'#a5b4fc',
].includes(color),
),
];
export function resolveCatalogTypeColor(
code: string,
colorMap: Record<string, string>,
index = 0,
fallbackColors: readonly string[] = CATALOG_FALLBACK_COLORS,
): string {
return colorMap[code] ?? fallbackColors[index % fallbackColors.length];
}

View File

@@ -1,56 +1,22 @@
import type { CSSProperties } from 'react';
import {
PROSTHESIS_FALLBACK_COLORS,
PROSTHESIS_TYPE_COLORS,
resolveCatalogTypeColor,
} from '@/components/ui/treatment/catalog-type-colors';
/**
* Prosthesis-type colors for lab-facing surfaces (Tasks list, Cases detail group
* headers / badges). Grouped by material family, loosely inspired by exocad's
* material color conventions:
* - Zirconia family → pale green/cream
* - PFM / full metal → steel gray
* - Glass-ceramic / IPS (press & CAD) → warm amber
* - Resin / PMMA / PEEK / temporary → mint/teal
* - Abutments / screw-retained → slate blue
* - Smile design / mockup → lavender/pink
* headers / badges). Uses a dedicated pastel map (unique per prosthesis code).
*
* Clinic-facing dispatch flows intentionally do NOT use these colors.
*/
const PROSTHESIS_TYPE_COLORS: Record<string, string> = {
// Zirconia family
monolithic_zirconia: '#d9f2e6',
pfz_crown: '#c7ede0',
veneer_zirconia: '#b8e6d5',
zirconia_abutment: '#a7dcc8',
zirconia_overlay: '#cdeede',
// PFM / metal
pfm_crown: '#cbd5e1',
full_metal_crown: '#b8c2cf',
// Glass-ceramic / IPS
glass_ceramic_crown: '#fde3a7',
veneer_ips_press: '#fcd88f',
veneer_ips_cad: '#f9cf9c',
ips_overlay: '#fbe0b0',
// Resin / PMMA / PEEK / temporary
temporary_resin_crown: '#bfeaf0',
pmma: '#a9e2ea',
peek_crown: '#b7e4dd',
soft_structure: '#d4eef0',
// Abutments / screw-retained
customized_abutment: '#aec6e8',
prefabricated_abutment: '#9db8e0',
ti_base_abutment: '#c0d0ec',
multi_unit_abutment: '#b4c4e6',
screw_retained: '#a8bce2',
// Design / mockup
smile_design: '#e9d5ff',
mockup: '#f5d0fe',
};
const FALLBACK_COLORS = ['#ddd6fe', '#fed7aa', '#fecaca', '#bae6fd', '#d9f99d', '#fbcfe8'];
/** Dark ink that stays readable on every pastel in the palette. */
const BADGE_INK = '#14253d';
export function prosthesisTypeColor(code: string, index = 0): string {
return PROSTHESIS_TYPE_COLORS[code] ?? FALLBACK_COLORS[index % FALLBACK_COLORS.length];
return resolveCatalogTypeColor(code, PROSTHESIS_TYPE_COLORS, index, PROSTHESIS_FALLBACK_COLORS);
}
/** Filled swatch (small indicator dots). */

View File

@@ -1,5 +1,9 @@
import type { CSSProperties } from 'react';
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
import {
resolveCatalogTypeColor,
TREATMENT_TYPE_COLORS,
} from '@/components/ui/treatment/catalog-type-colors';
/**
* Single source of truth for treatment-type colors across the app
@@ -10,23 +14,6 @@ import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
* conventions), so this is a curated pastel palette. Extend it as new treatment
* types are added; unknown codes fall back to a rotating pastel set by index.
*/
const TREATMENT_TYPE_COLORS: Record<string, string> = {
restoration: '#fed7aa',
specialized_restoration: '#fdba74',
radiography: '#cbd5e1',
endo: '#fecaca',
surgery: '#fca5a5',
prosthesis: '#c4b5fd',
implant: '#a5b4fc',
orthodontics: '#93c5fd',
perio: '#86efac',
pediatrics: '#fde68a',
extraction: '#f9a8d4',
clinic_visit: '#bae6fd',
continue_treatment: '#99f6e4',
};
const FALLBACK_COLORS = ['#ddd6fe', '#fed7aa', '#fecaca', '#bae6fd', '#d9f99d', '#fbcfe8'];
/** Dark ink that stays readable on every pastel in the palette. */
const BANNER_INK = '#14253d';
@@ -34,7 +21,7 @@ const BANNER_INK = '#14253d';
export const DROPDOWN_OPTION_BG = '#14253d';
export function treatmentTypeColor(code: string, index = 0): string {
return TREATMENT_TYPE_COLORS[code] ?? FALLBACK_COLORS[index % FALLBACK_COLORS.length];
return resolveCatalogTypeColor(code, TREATMENT_TYPE_COLORS, index);
}
/** Filled swatch (legend dots, small indicators). */