Shitty gadgets removed. Some useful gadgets added.

This commit is contained in:
2026-07-11 03:12:56 +03:30
parent 2f8c9f0f4d
commit ed8ff61205
31 changed files with 1489 additions and 170 deletions

View File

@@ -203,13 +203,31 @@
"widgetDraftTreatments": "Draft Treatments",
"widgetLabCasesPendingSend": "Lab Cases Pending Send",
"widgetCasesReceivedToday": "Cases Received Today",
"widgetCasesInProgress": "Cases In Progress",
"widgetTasksInProgress": "Tasks In Progress",
"widgetImportantTasks": "Important Tasks",
"widgetPendingConnections": "Pending Connections",
"widgetProvidersWithoutWorkingHours": "Providers Without Working Hours",
"widgetSeats": "Seat Usage",
"widgetPendingStaffInvites": "Pending Staff Invites",
"chartAppointmentsWeekAllTitle": "Appointments This Week",
"chartAppointmentsWeekAllSubtitle": "All providers — last 7 days",
"chartAppointmentsWeekMineTitle": "My Appointments This Week",
"chartAppointmentsWeekMineSubtitle": "Your schedule — last 7 days",
"chartLabTaskActivityTitle": "Lab Task Activity",
"chartLabTaskActivitySubtitle": "Last 7 days",
"chartLabTaskCompletedLegend": "Completed",
"chartLabTaskReceivedLegend": "Received",
"chartProsthesisMixTitle": "In-Progress Tasks by Prosthesis",
"chartProsthesisMixSubtitle": "Current workload mix",
"chartAppointmentsByProviderTitle": "Appointments by Provider",
"chartAppointmentsByProviderSubtitle": "Today",
"chartTreatmentMixTitle": "Treatment Mix",
"chartTreatmentMixSubtitle": "Last 7 days",
"chartCaseCompletionTitle": "Case Completion",
"chartCaseCompletionSubtitle": "All active cases",
"chartCaseCompletionPercent": "{percent}%",
"chartCaseCompletionTasks": "Tasks completed",
"chartTasksByStepTitle": "Tasks by Workflow Step",
"chartTasksByStepSubtitle": "In progress now",
"chartEmpty": "No data for this period yet.",

View File

@@ -203,13 +203,31 @@
"widgetDraftTreatments": "درمان‌های پیش‌نویس",
"widgetLabCasesPendingSend": "پرونده‌های در انتظار ارسال",
"widgetCasesReceivedToday": "پرونده‌های دریافتی امروز",
"widgetCasesInProgress": "پرونده‌های در حال انجام",
"widgetTasksInProgress": "وظایف در حال انجام",
"widgetImportantTasks": "وظایف مهم",
"widgetPendingConnections": "درخواست‌های اتصال در انتظار",
"widgetProvidersWithoutWorkingHours": "ارائه‌دهندگان بدون ساعات کاری",
"widgetSeats": "استفاده از صندلی",
"widgetPendingStaffInvites": "دعوت‌های کارکنان در انتظار",
"chartAppointmentsWeekAllTitle": "نوبت‌های این هفته",
"chartAppointmentsWeekAllSubtitle": "همه ارائه‌دهندگان — ۷ روز گذشته",
"chartAppointmentsWeekMineTitle": "نوبت‌های من این هفته",
"chartAppointmentsWeekMineSubtitle": "برنامه شما — ۷ روز گذشته",
"chartLabTaskActivityTitle": "فعالیت وظایف آزمایشگاه",
"chartLabTaskActivitySubtitle": "۷ روز گذشته",
"chartLabTaskCompletedLegend": "تکمیل‌شده",
"chartLabTaskReceivedLegend": "دریافت‌شده",
"chartProsthesisMixTitle": "وظایف در حال انجام بر اساس پروتز",
"chartProsthesisMixSubtitle": "ترکیب بار کاری فعلی",
"chartAppointmentsByProviderTitle": "نوبت‌ها بر اساس ارائه‌دهنده",
"chartAppointmentsByProviderSubtitle": "امروز",
"chartTreatmentMixTitle": "ترکیب درمان‌ها",
"chartTreatmentMixSubtitle": "۷ روز گذشته",
"chartCaseCompletionTitle": "تکمیل پرونده‌ها",
"chartCaseCompletionSubtitle": "همه پرونده‌های فعال",
"chartCaseCompletionPercent": "{percent}٪",
"chartCaseCompletionTasks": "وظایف تکمیل‌شده",
"chartTasksByStepTitle": "وظایف بر اساس مرحله گردش کار",
"chartTasksByStepSubtitle": "در حال انجام",
"chartEmpty": "هنوز داده‌ای برای این بازه وجود ندارد.",

View File

@@ -203,13 +203,31 @@
"widgetDraftTreatments": "Conceptbehandelingen",
"widgetLabCasesPendingSend": "Labcases wachten op verzending",
"widgetCasesReceivedToday": "Cases ontvangen vandaag",
"widgetCasesInProgress": "Cases in uitvoering",
"widgetTasksInProgress": "Taken in uitvoering",
"widgetImportantTasks": "Belangrijke taken",
"widgetPendingConnections": "Openstaande koppelingsverzoeken",
"widgetProvidersWithoutWorkingHours": "Behandelaars zonder werktijden",
"widgetSeats": "Zitplaatsgebruik",
"widgetPendingStaffInvites": "Openstaande medewerkersuitnodigingen",
"chartAppointmentsWeekAllTitle": "Afspraken deze week",
"chartAppointmentsWeekAllSubtitle": "Alle behandelaars — afgelopen 7 dagen",
"chartAppointmentsWeekMineTitle": "Mijn afspraken deze week",
"chartAppointmentsWeekMineSubtitle": "Uw planning — afgelopen 7 dagen",
"chartLabTaskActivityTitle": "Labtaakactiviteit",
"chartLabTaskActivitySubtitle": "Afgelopen 7 dagen",
"chartLabTaskCompletedLegend": "Voltooid",
"chartLabTaskReceivedLegend": "Ontvangen",
"chartProsthesisMixTitle": "Lopende taken per prothese",
"chartProsthesisMixSubtitle": "Huidige werklastmix",
"chartAppointmentsByProviderTitle": "Afspraken per behandelaar",
"chartAppointmentsByProviderSubtitle": "Vandaag",
"chartTreatmentMixTitle": "Behandelingsmix",
"chartTreatmentMixSubtitle": "Afgelopen 7 dagen",
"chartCaseCompletionTitle": "Casevoltooiing",
"chartCaseCompletionSubtitle": "Alle actieve cases",
"chartCaseCompletionPercent": "{percent}%",
"chartCaseCompletionTasks": "Taken voltooid",
"chartTasksByStepTitle": "Taken per workflowstap",
"chartTasksByStepSubtitle": "Nu in uitvoering",
"chartEmpty": "Nog geen gegevens voor deze periode.",

View File

@@ -6,6 +6,9 @@ import { Link } from '@/i18n/navigation';
import { useAuth } from '@/lib/hooks/useAuth';
import {
canAccessAppointmentsSection,
canViewAppointmentsTab,
canViewCases,
canViewLabCasesOrTasks,
canViewTasks,
canViewTreatment,
} from '@/components/shared/permissions';
@@ -27,17 +30,26 @@ export default function TodayPage() {
const showNoSubscriptionNotice =
Boolean(currentOrganization?.isOwner) && !currentOrganization?.plan;
const showUpcoming = canAccessAppointmentsSection(currentOrganization);
const showCharts =
(currentOrganization?.type === 'CLINIC' && canViewTreatment(currentOrganization)) ||
(currentOrganization?.type === 'LAB' && canViewTasks(currentOrganization));
const showUpcoming =
currentOrganization?.type === 'CLINIC' && canViewTreatment(currentOrganization);
const showCharts = useMemo(() => {
const orgType = currentOrganization?.type;
if (!orgType) return false;
const actionLayoutClass = useMemo(() => {
if (showUpcoming && showCharts) {
return 'grid grid-cols-1 xl:grid-cols-2 gap-4 items-start';
if (orgType === 'CLINIC') {
return (
canAccessAppointmentsSection(currentOrganization) ||
canViewAppointmentsTab(currentOrganization) ||
canViewTreatment(currentOrganization)
);
}
return 'grid grid-cols-1 gap-4';
}, [showUpcoming, showCharts]);
return (
canViewCases(currentOrganization) ||
canViewTasks(currentOrganization) ||
canViewLabCasesOrTasks(currentOrganization)
);
}, [currentOrganization]);
const sectionErrorMessage = t('sectionLoadError');
@@ -89,33 +101,38 @@ export default function TodayPage() {
/>
</TodayWidgetErrorBoundary>
{(showUpcoming || showCharts) && (!error || data) ? (
<div className={actionLayoutClass}>
{showUpcoming ? (
<TodayWidgetErrorBoundary
fallback={<TodaySectionErrorFallback message={sectionErrorMessage} />}
>
<TodayUpcomingAppointments
actions={data?.actions ?? {}}
loading={loading}
isInitialLoad={isInitialLoad}
/>
</TodayWidgetErrorBoundary>
) : null}
{showCharts ? (
<TodayWidgetErrorBoundary
fallback={<TodaySectionErrorFallback message={sectionErrorMessage} />}
>
{showUpcoming && (!error || data) ? (
<TodayWidgetErrorBoundary
fallback={<TodaySectionErrorFallback message={sectionErrorMessage} />}
>
<div
className={`grid grid-cols-1 gap-4 lg:grid-cols-2 ${loading ? 'opacity-70 transition-opacity' : ''}`}
>
<TodayUpcomingAppointments
actions={data?.actions ?? {}}
loading={loading}
isInitialLoad={isInitialLoad}
/>
{showCharts ? (
<TodayChartsSection
charts={data?.charts ?? {}}
loading={loading}
isInitialLoad={isInitialLoad}
className={showUpcoming ? '' : 'max-w-none'}
embedded
/>
</TodayWidgetErrorBoundary>
) : null}
</div>
) : null}
</div>
</TodayWidgetErrorBoundary>
) : showCharts && (!error || data) ? (
<TodayWidgetErrorBoundary
fallback={<TodaySectionErrorFallback message={sectionErrorMessage} />}
>
<TodayChartsSection
charts={data?.charts ?? {}}
loading={loading}
isInitialLoad={isInitialLoad}
/>
</TodayWidgetErrorBoundary>
) : null}
</div>
);

View File

@@ -1,12 +1,15 @@
'use client';
import { useTranslations } from 'next-intl';
import { useSearchParams } from 'next/navigation';
import { TreatmentWorkspace } from '@/components/ui/treatment/TreatmentWorkspace';
import { useAuth } from '@/lib/hooks/useAuth';
export default function TreatmentPage() {
const t = useTranslations('treatment');
const { user, currentOrganization, isAuthReady } = useAuth();
const searchParams = useSearchParams();
const initialAppointmentId = searchParams.get('appointmentId');
if (!isAuthReady || !user) {
return (
@@ -15,6 +18,10 @@ export default function TreatmentPage() {
}
return (
<TreatmentWorkspace userId={user.id} currentOrganization={currentOrganization} />
<TreatmentWorkspace
userId={user.id}
currentOrganization={currentOrganization}
initialAppointmentId={initialAppointmentId}
/>
);
}

View File

@@ -210,3 +210,18 @@ export function canEditTasks(org: Organization | null): boolean {
if (org.isOwner) return true;
return hasPermission(org, 'TAB_TASKS_EDIT');
}
/** Appointments tab only (excludes treatment-only access). */
export function canViewAppointmentsTab(org: Organization | null): boolean {
if (!org) return false;
if (org.type !== 'CLINIC') return false;
if (org.isOwner) return true;
return (
hasPermission(org, 'TAB_APPOINTMENTS_READ') ||
hasPermission(org, 'TAB_APPOINTMENTS_EDIT')
);
}
export function canViewLabCasesOrTasks(org: Organization | null): boolean {
return canViewCases(org) || canViewTasks(org);
}

View File

@@ -2,8 +2,9 @@ import { isSameLocalCalendarDay } from '@/components/appointments/appointmentTim
import type { TreatmentAppointment } from '@/types/treatment';
/**
* For the selected calendar day: if it is today, pick the appointment whose time range contains now;
* otherwise pick the first appointment of that day. Returns null when there are no appointments.
* For the selected calendar day: if it is today, pick the in-progress appointment,
* otherwise the appointment whose start time is nearest to now; on other days pick
* the first appointment of that day. Returns null when there are no appointments.
*/
export function pickAutoAppointment(
appointments: TreatmentAppointment[],
@@ -19,7 +20,23 @@ export function pickAutoAppointment(
const e = new Date(a.endAt).getTime();
if (t >= s && t <= e) return a.id;
}
let nearest = appointments[0];
let nearestDistance = Math.abs(new Date(nearest.startAt).getTime() - t);
for (const appointment of appointments.slice(1)) {
const distance = Math.abs(new Date(appointment.startAt).getTime() - t);
if (distance < nearestDistance) {
nearest = appointment;
nearestDistance = distance;
}
}
return nearest.id;
}
return appointments[0].id;
}
export function treatmentAppointmentHref(appointmentId?: string): string {
if (!appointmentId) return '/treatment';
return `/treatment?appointmentId=${encodeURIComponent(appointmentId)}`;
}

View File

@@ -37,7 +37,7 @@ export function ChartCard({
<p className="text-sm text-text-muted text-center px-4">{emptyMessage}</p>
</div>
) : (
<div className="flex-1 min-h-[220px]">{children}</div>
<div className="flex flex-1 min-h-[220px] flex-col">{children}</div>
)}
</Card>
);

View File

@@ -37,7 +37,12 @@ export function KpiCard({
>
<div className="flex items-start justify-between gap-3">
<p className="text-sm font-medium">{title}</p>
{Icon ? <Icon className="h-4 w-4 shrink-0 opacity-80" aria-hidden /> : null}
{Icon ? (
<Icon
className="h-4 w-4 shrink-0 !text-current"
aria-hidden
/>
) : null}
</div>
{loading ? (
<div className="mt-2 h-8 w-16 animate-pulse rounded bg-current/10" />

View File

@@ -0,0 +1,66 @@
'use client';
import {
Area,
AreaChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import type { TodayChartBucket } from '@/types/today';
import {
TODAY_CHART_AXIS_COLOR,
TODAY_CHART_GRID_COLOR,
TODAY_CHART_PRIMARY_COLOR,
TODAY_CHART_TOOLTIP_STYLE,
} from '@/components/today/chart-theme';
interface TodayAreaChartProps {
data: TodayChartBucket[];
}
export function TodayAreaChart({ data }: TodayAreaChartProps) {
return (
<ResponsiveContainer width="100%" height={220}>
<AreaChart data={data} margin={{ top: 8, right: 8, left: -12, bottom: 0 }}>
<defs>
<linearGradient id="todayAreaFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={TODAY_CHART_PRIMARY_COLOR} stopOpacity={0.45} />
<stop offset="100%" stopColor={TODAY_CHART_PRIMARY_COLOR} stopOpacity={0.05} />
</linearGradient>
</defs>
<CartesianGrid stroke={TODAY_CHART_GRID_COLOR} vertical={false} />
<XAxis
dataKey="label"
tick={{ fill: TODAY_CHART_AXIS_COLOR, fontSize: 11 }}
axisLine={{ stroke: TODAY_CHART_GRID_COLOR }}
tickLine={false}
interval={1}
/>
<YAxis
allowDecimals={false}
tick={{ fill: TODAY_CHART_AXIS_COLOR, fontSize: 11 }}
axisLine={false}
tickLine={false}
width={32}
/>
<Tooltip
cursor={{ stroke: 'rgba(0, 188, 255, 0.25)' }}
contentStyle={TODAY_CHART_TOOLTIP_STYLE}
labelFormatter={(label) => String(label)}
/>
<Area
type="monotone"
dataKey="count"
stroke={TODAY_CHART_PRIMARY_COLOR}
strokeWidth={2}
fill="url(#todayAreaFill)"
dot={{ r: 3, fill: TODAY_CHART_PRIMARY_COLOR, strokeWidth: 0 }}
activeDot={{ r: 5, fill: TODAY_CHART_PRIMARY_COLOR }}
/>
</AreaChart>
</ResponsiveContainer>
);
}

View File

@@ -21,9 +21,10 @@ import {
interface TodayBarChartProps {
data: TodayChartBucket[];
colorForCode?: (code: string, index: number) => string;
}
export function TodayBarChart({ data }: TodayBarChartProps) {
export function TodayBarChart({ data, colorForCode }: TodayBarChartProps) {
const chartData = data.map((item) => ({
...item,
shortLabel: truncateLabel(item.label),
@@ -38,7 +39,35 @@ export function TodayBarChart({ data }: TodayBarChartProps) {
<CartesianGrid stroke={TODAY_CHART_GRID_COLOR} vertical={false} />
<XAxis
dataKey="shortLabel"
tick={{ fill: TODAY_CHART_AXIS_COLOR, fontSize: 11 }}
tick={
colorForCode
? (props) => {
const { x, y, payload } = props as {
x: number;
y: number;
payload: { value: string };
};
const index = chartData.findIndex((row) => row.shortLabel === payload.value);
const entry = chartData[index];
const fill =
entry != null
? colorForCode(entry.code, index >= 0 ? index : 0)
: TODAY_CHART_AXIS_COLOR;
return (
<text
x={x}
y={y}
dy={16}
textAnchor="middle"
fill={fill}
fontSize={11}
>
{payload.value}
</text>
);
}
: { fill: TODAY_CHART_AXIS_COLOR, fontSize: 11 }
}
axisLine={{ stroke: TODAY_CHART_GRID_COLOR }}
tickLine={false}
interval={0}
@@ -68,7 +97,10 @@ export function TodayBarChart({ data }: TodayBarChartProps) {
{chartData.map((entry, index) => (
<Cell
key={entry.code}
fill={TODAY_CHART_COLORS[index % TODAY_CHART_COLORS.length]}
fill={
colorForCode?.(entry.code, index) ??
TODAY_CHART_COLORS[index % TODAY_CHART_COLORS.length]
}
/>
))}
</Bar>

View File

@@ -1,14 +1,23 @@
'use client';
import { useMemo } from 'react';
import { useTranslations } from 'next-intl';
import { useAuth } from '@/lib/hooks/useAuth';
import {
canViewTasks,
canViewTreatment,
} from '@/components/shared/permissions';
import { ChartCard } from '@/components/today/ChartCard';
import { TodayAreaChart } from '@/components/today/TodayAreaChart';
import { TodayBarChart } from '@/components/today/TodayBarChart';
import {
formatTodayChartDayLabel,
mapWeekChartBuckets,
useTodayDayLabelFormatter,
} from '@/components/today/chart-day-labels';
import { TodayDonutChart } from '@/components/today/TodayDonutChart';
import { TodayHorizontalBarChart } from '@/components/today/TodayHorizontalBarChart';
import { TodayRadialGaugeChart } from '@/components/today/TodayRadialGaugeChart';
import { TodayStackedBarChart } from '@/components/today/TodayStackedBarChart';
import { ChartCardSkeleton } from '@/components/today/TodaySkeleton';
import { prosthesisTypeColor, prosthesisTypeSwatchStyle } from '@/components/ui/treatment/prosthesisTypeDisplay';
import { treatmentTypeColor } from '@/components/ui/treatment/treatmentTypeDisplay';
import type { TodaySummaryCharts } from '@/types/today';
interface TodayChartsSectionProps {
@@ -16,6 +25,8 @@ interface TodayChartsSectionProps {
loading?: boolean;
isInitialLoad?: boolean;
className?: string;
/** When true, chart cards render as siblings (no outer grid wrapper). */
embedded?: boolean;
}
export function TodayChartsSection({
@@ -23,38 +34,155 @@ export function TodayChartsSection({
loading = false,
isInitialLoad = false,
className = '',
embedded = false,
}: TodayChartsSectionProps) {
const t = useTranslations('today');
const dayLabelFormatter = useTodayDayLabelFormatter();
const { currentOrganization } = useAuth();
const orgType = currentOrganization?.type;
const showAppointmentsByProvider =
orgType === 'CLINIC' && charts.appointmentsByProvider !== undefined;
const showAppointmentsWeekAll =
orgType === 'CLINIC' && charts.appointmentsWeekAll !== undefined;
const showAppointmentsWeekMine =
orgType === 'CLINIC' && charts.appointmentsWeekMine !== undefined;
const showTreatmentMix =
orgType === 'CLINIC' && canViewTreatment(currentOrganization);
orgType === 'CLINIC' && charts.treatmentMixWeek !== undefined;
const showCaseCompletion =
orgType === 'LAB' && charts.caseCompletion !== undefined;
const showTasksByStep =
orgType === 'LAB' && canViewTasks(currentOrganization);
orgType === 'LAB' && charts.tasksByWorkflowStep !== undefined;
const showLabTaskActivityWeek =
orgType === 'LAB' && charts.labTaskActivityWeek !== undefined;
const showInProgressTasksByProsthesis =
orgType === 'LAB' && charts.inProgressTasksByProsthesis !== undefined;
if (!showTreatmentMix && !showTasksByStep) {
const visibleChartCount =
Number(showAppointmentsByProvider) +
Number(showAppointmentsWeekAll) +
Number(showAppointmentsWeekMine) +
Number(showTreatmentMix) +
Number(showCaseCompletion) +
Number(showTasksByStep) +
Number(showLabTaskActivityWeek) +
Number(showInProgressTasksByProsthesis);
const appointmentsByProviderData = charts.appointmentsByProvider ?? [];
const appointmentsWeekAllData = useMemo(
() => mapWeekChartBuckets(charts.appointmentsWeekAll ?? [], dayLabelFormatter),
[charts.appointmentsWeekAll, dayLabelFormatter],
);
const appointmentsWeekMineData = useMemo(
() => mapWeekChartBuckets(charts.appointmentsWeekMine ?? [], dayLabelFormatter),
[charts.appointmentsWeekMine, dayLabelFormatter],
);
const treatmentData = charts.treatmentMixWeek ?? [];
const tasksData = charts.tasksByWorkflowStep ?? [];
const labTaskActivityData = useMemo(
() => mapWeekChartBuckets(charts.labTaskActivityWeek ?? [], dayLabelFormatter),
[charts.labTaskActivityWeek, dayLabelFormatter],
);
const prosthesisData = charts.inProgressTasksByProsthesis ?? [];
const caseCompletion = charts.caseCompletion ?? { completed: 0, total: 0, percent: 0 };
const formatDayLabel = (code: string) =>
formatTodayChartDayLabel(code, dayLabelFormatter);
if (visibleChartCount === 0) {
return null;
}
const treatmentData = charts.treatmentMixWeek ?? [];
const tasksData = charts.tasksByWorkflowStep ?? [];
if (isInitialLoad) {
const skeletons = Array.from({ length: Math.min(visibleChartCount, 4) }).map((_, index) => (
<ChartCardSkeleton key={index} />
));
if (embedded) {
return <>{skeletons}</>;
}
return (
<div className={`grid grid-cols-1 gap-4 ${className}`}>
{showTreatmentMix ? <ChartCardSkeleton /> : null}
{showTasksByStep ? <ChartCardSkeleton /> : null}
</div>
<div className={`grid grid-cols-1 gap-4 lg:grid-cols-2 ${className}`}>{skeletons}</div>
);
}
const chartCount = (showTreatmentMix ? 1 : 0) + (showTasksByStep ? 1 : 0);
const gridClass =
visibleChartCount > 1 ? 'grid grid-cols-1 lg:grid-cols-2' : 'grid grid-cols-1';
const chartCards = (
<>
{showAppointmentsWeekAll ? (
<ChartCard
title={t('chartAppointmentsWeekAllTitle')}
subtitle={t('chartAppointmentsWeekAllSubtitle')}
isEmpty={appointmentsWeekAllData.every((row) => row.count === 0)}
emptyMessage={t('chartEmpty')}
>
<TodayAreaChart data={appointmentsWeekAllData} />
</ChartCard>
) : null}
{showAppointmentsWeekMine ? (
<ChartCard
title={t('chartAppointmentsWeekMineTitle')}
subtitle={t('chartAppointmentsWeekMineSubtitle')}
isEmpty={appointmentsWeekMineData.every((row) => row.count === 0)}
emptyMessage={t('chartEmpty')}
>
<TodayAreaChart data={appointmentsWeekMineData} />
</ChartCard>
) : null}
{showLabTaskActivityWeek ? (
<ChartCard
title={t('chartLabTaskActivityTitle')}
subtitle={t('chartLabTaskActivitySubtitle')}
isEmpty={labTaskActivityData.every(
(row) => row.completed === 0 && row.received === 0,
)}
emptyMessage={t('chartEmpty')}
>
<TodayStackedBarChart
data={labTaskActivityData}
completedLabel={t('chartLabTaskCompletedLegend')}
receivedLabel={t('chartLabTaskReceivedLegend')}
formatDayLabel={formatDayLabel}
/>
</ChartCard>
) : null}
{showInProgressTasksByProsthesis ? (
<ChartCard
title={t('chartProsthesisMixTitle')}
subtitle={t('chartProsthesisMixSubtitle')}
isEmpty={prosthesisData.length === 0}
emptyMessage={t('chartEmpty')}
>
<TodayDonutChart
data={prosthesisData}
labelForCode={(code) =>
prosthesisData.find((row) => row.code === code)?.label ?? code
}
colorForCode={(code, index) => prosthesisTypeColor(code, index)}
swatchStyleForCode={(code, index) => prosthesisTypeSwatchStyle(code, index)}
variant="pie"
sideLegend
/>
</ChartCard>
) : null}
{showAppointmentsByProvider ? (
<ChartCard
title={t('chartAppointmentsByProviderTitle')}
subtitle={t('chartAppointmentsByProviderSubtitle')}
isEmpty={appointmentsByProviderData.length === 0}
emptyMessage={t('chartEmpty')}
>
<TodayHorizontalBarChart data={appointmentsByProviderData} />
</ChartCard>
) : null}
return (
<div
className={`grid grid-cols-1 ${chartCount > 1 ? 'lg:grid-cols-2' : ''} gap-4 ${loading ? 'opacity-70 transition-opacity' : ''} ${className}`}
>
{showTreatmentMix ? (
<ChartCard
title={t('chartTreatmentMixTitle')}
@@ -62,7 +190,29 @@ export function TodayChartsSection({
isEmpty={treatmentData.length === 0}
emptyMessage={t('chartEmpty')}
>
<TodayBarChart data={treatmentData} />
<TodayBarChart
data={treatmentData}
colorForCode={(code, index) => treatmentTypeColor(code, index)}
/>
</ChartCard>
) : null}
{showCaseCompletion ? (
<ChartCard
title={t('chartCaseCompletionTitle')}
subtitle={t('chartCaseCompletionSubtitle')}
isEmpty={caseCompletion.total === 0}
emptyMessage={t('chartEmpty')}
>
<TodayRadialGaugeChart
percent={caseCompletion.percent}
completed={caseCompletion.completed}
total={caseCompletion.total}
percentLabel={t('chartCaseCompletionPercent', {
percent: caseCompletion.percent,
})}
tasksLabel={t('chartCaseCompletionTasks')}
/>
</ChartCard>
) : null}
@@ -76,6 +226,18 @@ export function TodayChartsSection({
<TodayBarChart data={tasksData} />
</ChartCard>
) : null}
</>
);
if (embedded) {
return chartCards;
}
return (
<div
className={`${gridClass} gap-4 ${loading ? 'opacity-70 transition-opacity' : ''} ${className}`}
>
{chartCards}
</div>
);
}

View File

@@ -0,0 +1,125 @@
'use client';
import type { CSSProperties } from 'react';
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from 'recharts';
import type { TodayChartBucket } from '@/types/today';
import {
TODAY_CHART_COLORS,
TODAY_CHART_TOOLTIP_STYLE,
} from '@/components/today/chart-theme';
interface TodayDonutChartProps {
data: TodayChartBucket[];
labelForCode: (code: string) => string;
colorForCode?: (code: string, index: number) => string;
swatchStyleForCode?: (code: string, index: number) => CSSProperties;
variant?: 'donut' | 'pie';
sideLegend?: boolean;
}
export function TodayDonutChart({
data,
labelForCode,
colorForCode,
swatchStyleForCode,
variant = 'donut',
sideLegend = false,
}: TodayDonutChartProps) {
const chartData = data.map((item) => ({
...item,
displayLabel: labelForCode(item.code),
}));
const resolveColor = (code: string, index: number) =>
colorForCode?.(code, index) ??
TODAY_CHART_COLORS[index % TODAY_CHART_COLORS.length];
const resolveSwatchStyle = (code: string, index: number): CSSProperties =>
swatchStyleForCode?.(code, index) ?? {
backgroundColor: resolveColor(code, index),
borderColor: 'rgba(0, 0, 0, 0.18)',
};
const innerRadius = variant === 'pie' ? 0 : 62;
const outerRadius = sideLegend ? 100 : 92;
const chart = (
<ResponsiveContainer width="100%" height={240}>
<PieChart margin={{ top: 0, right: 0, bottom: 0, left: 0 }}>
<Pie
data={chartData}
dataKey="count"
nameKey="displayLabel"
cx="50%"
cy="50%"
innerRadius={innerRadius}
outerRadius={outerRadius}
paddingAngle={variant === 'pie' ? 1 : 2}
stroke="transparent"
>
{chartData.map((entry, index) => (
<Cell key={entry.code} fill={resolveColor(entry.code, index)} />
))}
</Pie>
<Tooltip
contentStyle={TODAY_CHART_TOOLTIP_STYLE}
formatter={(value, _name, item) => {
const row = item?.payload as TodayChartBucket | undefined;
return [value, row ? labelForCode(row.code) : ''];
}}
/>
</PieChart>
</ResponsiveContainer>
);
if (!sideLegend) {
return chart;
}
const rowClass = 'flex h-4 items-center text-xs leading-none';
const legendInset = 'px-12';
return (
<div className={`flex h-full min-h-[220px] items-center ${legendInset}`}>
<div className="flex min-w-0 flex-1 items-center overflow-y-auto max-h-full py-0.5">
<div className="flex flex-col items-start gap-1.5 shrink-0">
{chartData.map((entry, index) => (
<span key={entry.code} className={rowClass}>
<span
className="inline-block h-3 w-3 rounded-sm border"
style={resolveSwatchStyle(entry.code, index)}
aria-hidden
/>
</span>
))}
</div>
<div className="ml-2 flex flex-col items-start gap-1.5">
{chartData.map((entry) => (
<span
key={entry.code}
className={`${rowClass} max-w-full truncate text-left text-text-primary`}
>
{entry.displayLabel}
</span>
))}
</div>
<div className="ml-3 flex shrink-0 flex-col items-end gap-1.5">
{chartData.map((entry) => (
<span
key={entry.code}
className={`${rowClass} tabular-nums text-right text-text-muted`}
>
{entry.count}
</span>
))}
</div>
</div>
<div className="ml-4 flex h-[240px] w-[min(100%,220px)] max-w-[48%] shrink-0 items-center justify-center">
{chart}
</div>
</div>
);
}

View File

@@ -0,0 +1,78 @@
'use client';
import {
Bar,
BarChart,
CartesianGrid,
Cell,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import type { TodayChartBucket } from '@/types/today';
import {
TODAY_CHART_AXIS_COLOR,
TODAY_CHART_COLORS,
TODAY_CHART_GRID_COLOR,
TODAY_CHART_TOOLTIP_STYLE,
} from '@/components/today/chart-theme';
interface TodayHorizontalBarChartProps {
data: TodayChartBucket[];
}
export function TodayHorizontalBarChart({ data }: TodayHorizontalBarChartProps) {
const chartData = data.map((item) => ({
...item,
shortLabel: truncateLabel(item.label, 18),
}));
return (
<ResponsiveContainer width="100%" height={Math.max(220, chartData.length * 36)}>
<BarChart
data={chartData}
layout="vertical"
margin={{ top: 4, right: 12, left: 4, bottom: 0 }}
>
<CartesianGrid stroke={TODAY_CHART_GRID_COLOR} horizontal={false} />
<XAxis
type="number"
allowDecimals={false}
tick={{ fill: TODAY_CHART_AXIS_COLOR, fontSize: 11 }}
axisLine={{ stroke: TODAY_CHART_GRID_COLOR }}
tickLine={false}
/>
<YAxis
type="category"
dataKey="shortLabel"
width={96}
tick={{ fill: TODAY_CHART_AXIS_COLOR, fontSize: 11 }}
axisLine={false}
tickLine={false}
/>
<Tooltip
cursor={{ fill: 'rgba(0, 188, 255, 0.08)' }}
contentStyle={TODAY_CHART_TOOLTIP_STYLE}
labelFormatter={(_, payload) => {
const row = payload?.[0]?.payload as TodayChartBucket | undefined;
return row?.label ?? '';
}}
/>
<Bar dataKey="count" radius={[0, 4, 4, 0]} maxBarSize={28}>
{chartData.map((entry, index) => (
<Cell
key={entry.code}
fill={TODAY_CHART_COLORS[index % TODAY_CHART_COLORS.length]}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
);
}
function truncateLabel(label: string, max = 18): string {
if (label.length <= max) return label;
return `${label.slice(0, max - 1)}`;
}

View File

@@ -0,0 +1,56 @@
'use client';
import { RadialBar, RadialBarChart, ResponsiveContainer } from 'recharts';
import { TODAY_CHART_PRIMARY_COLOR } from '@/components/today/chart-theme';
interface TodayRadialGaugeChartProps {
percent: number;
completed: number;
total: number;
percentLabel: string;
tasksLabel: string;
}
export function TodayRadialGaugeChart({
percent,
completed,
total,
percentLabel,
tasksLabel,
}: TodayRadialGaugeChartProps) {
const clamped = Math.max(0, Math.min(100, percent));
const data = [{ name: 'completion', value: clamped, fill: TODAY_CHART_PRIMARY_COLOR }];
return (
<div className="relative h-[240px] w-full">
<ResponsiveContainer width="100%" height="100%">
<RadialBarChart
cx="50%"
cy="50%"
innerRadius="68%"
outerRadius="100%"
barSize={14}
data={data}
startAngle={90}
endAngle={-270}
>
<RadialBar
background={{ fill: 'rgba(41, 69, 106, 0.55)' }}
dataKey="value"
cornerRadius={8}
/>
</RadialBarChart>
</ResponsiveContainer>
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center text-center">
<span className="text-3xl font-semibold text-text-primary">{percentLabel}</span>
<span className="mt-1 text-xs text-text-muted">{tasksLabel}</span>
{total > 0 ? (
<span className="mt-0.5 text-[11px] text-text-secondary">
{completed}/{total}
</span>
) : null}
</div>
</div>
);
}

View File

@@ -31,6 +31,6 @@ export function ChartCardSkeleton() {
);
}
export function ListRowSkeleton() {
return <SkeletonBlock className="h-12 w-full" />;
export function ListRowSkeleton({ compact = false }: { compact?: boolean }) {
return <SkeletonBlock className={`w-full ${compact ? 'h-8' : 'h-12'}`} />;
}

View File

@@ -0,0 +1,87 @@
'use client';
import {
Bar,
BarChart,
CartesianGrid,
Legend,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import type { TodayStackedDayBucket } from '@/types/today';
import {
TODAY_CHART_AXIS_COLOR,
TODAY_CHART_COMPLETED_COLOR,
TODAY_CHART_GRID_COLOR,
TODAY_CHART_RECEIVED_COLOR,
TODAY_CHART_TOOLTIP_STYLE,
} from '@/components/today/chart-theme';
interface TodayStackedBarChartProps {
data: TodayStackedDayBucket[];
completedLabel: string;
receivedLabel: string;
formatDayLabel: (code: string) => string;
}
export function TodayStackedBarChart({
data,
completedLabel,
receivedLabel,
formatDayLabel,
}: TodayStackedBarChartProps) {
const chartData = data.map((item) => ({
...item,
dayLabel: formatDayLabel(item.code),
}));
return (
<ResponsiveContainer width="100%" height={240}>
<BarChart data={chartData} margin={{ top: 8, right: 8, left: -12, bottom: 0 }}>
<CartesianGrid stroke={TODAY_CHART_GRID_COLOR} vertical={false} />
<XAxis
dataKey="dayLabel"
tick={{ fill: TODAY_CHART_AXIS_COLOR, fontSize: 11 }}
axisLine={{ stroke: TODAY_CHART_GRID_COLOR }}
tickLine={false}
/>
<YAxis
allowDecimals={false}
tick={{ fill: TODAY_CHART_AXIS_COLOR, fontSize: 11 }}
axisLine={false}
tickLine={false}
width={32}
/>
<Tooltip
cursor={{ fill: 'rgba(0, 188, 255, 0.08)' }}
contentStyle={TODAY_CHART_TOOLTIP_STYLE}
labelFormatter={(_, payload) => {
const row = payload?.[0]?.payload as TodayStackedDayBucket | undefined;
return row ? formatDayLabel(row.code) : '';
}}
/>
<Legend
wrapperStyle={{ fontSize: '12px', color: TODAY_CHART_AXIS_COLOR }}
/>
<Bar
dataKey="completed"
name={completedLabel}
stackId="activity"
fill={TODAY_CHART_COMPLETED_COLOR}
radius={[0, 0, 0, 0]}
maxBarSize={48}
/>
<Bar
dataKey="received"
name={receivedLabel}
stackId="activity"
fill={TODAY_CHART_RECEIVED_COLOR}
radius={[4, 4, 0, 0]}
maxBarSize={48}
/>
</BarChart>
</ResponsiveContainer>
);
}

View File

@@ -1,13 +1,19 @@
'use client';
import { useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { ChevronRight } from 'lucide-react';
import { Link } from '@/i18n/navigation';
import { Card } from '@/components/ui/shared/Card';
import { formatTimeForInput } from '@/components/appointments/appointmentTime';
import { canAccessAppointmentsSection } from '@/components/shared/permissions';
import { purposeLabel } from '@/components/ui/appointments/appointmentPurposeStyles';
import { treatmentAppointmentHref } from '@/components/shared/treatmentSelection';
import { treatmentTypeColor } from '@/components/ui/treatment/treatmentTypeDisplay';
import { canViewTreatment } from '@/components/shared/permissions';
import { useAuth } from '@/lib/hooks/useAuth';
import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
import { ListRowSkeleton } from '@/components/today/TodaySkeleton';
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
import type { TodaySummaryActions } from '@/types/today';
interface TodayUpcomingAppointmentsProps {
@@ -16,6 +22,8 @@ interface TodayUpcomingAppointmentsProps {
isInitialLoad?: boolean;
}
const MAX_VISIBLE = 3;
export function TodayUpcomingAppointments({
actions,
loading = false,
@@ -23,23 +31,35 @@ export function TodayUpcomingAppointments({
}: TodayUpcomingAppointmentsProps) {
const t = useTranslations('today');
const { currentOrganization } = useAuth();
const [treatmentCatalog, setTreatmentCatalog] = useState<TreatmentCatalogEntry[]>([]);
if (!canAccessAppointmentsSection(currentOrganization)) {
useEffect(() => {
void treatmentCatalogApi
.list()
.then((response) => setTreatmentCatalog(response.data))
.catch(() => {});
}, []);
if (
!currentOrganization ||
currentOrganization.type !== 'CLINIC' ||
!canViewTreatment(currentOrganization)
) {
return null;
}
const appointments = actions.upcomingAppointmentsToday ?? [];
const appointments = (actions.upcomingAppointmentsToday ?? []).slice(0, MAX_VISIBLE);
if (isInitialLoad) {
return (
<Card className="min-h-[280px]">
<div className="mb-4 space-y-2">
<div className="h-4 w-40 animate-pulse rounded bg-background-secondary/60" />
<div className="h-3 w-56 animate-pulse rounded bg-background-secondary/60" />
<Card className="min-h-[140px] p-3">
<div className="mb-2 space-y-1.5">
<div className="h-3.5 w-32 animate-pulse rounded bg-background-secondary/60" />
<div className="h-3 w-44 animate-pulse rounded bg-background-secondary/60" />
</div>
<div className="space-y-3">
{[0, 1, 2].map((key) => (
<ListRowSkeleton key={key} />
<div className="space-y-2">
{[0, 1].map((key) => (
<ListRowSkeleton key={key} compact />
))}
</div>
</Card>
@@ -47,25 +67,25 @@ export function TodayUpcomingAppointments({
}
return (
<Card className={`min-h-[280px] ${loading ? 'opacity-70 transition-opacity' : ''}`}>
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3 mb-4">
<Card className="min-h-[140px] p-3">
<div className="mb-2 flex flex-col gap-1 sm:flex-row sm:items-start sm:justify-between">
<div>
<h2 className="text-base font-semibold text-card-foreground">
<h2 className="text-sm font-semibold text-card-foreground">
{t('upcomingAppointmentsTitle')}
</h2>
<p className="text-xs text-text-muted mt-1">{t('upcomingAppointmentsSubtitle')}</p>
<p className="mt-0.5 text-[11px] text-text-muted">{t('upcomingAppointmentsSubtitle')}</p>
</div>
<Link
href="/appointments"
className="text-xs font-medium text-primary hover:underline underline-offset-2 shrink-0"
href={treatmentAppointmentHref()}
className="shrink-0 text-[11px] font-medium text-primary hover:underline underline-offset-2"
>
{t('viewAllAppointments')}
</Link>
</div>
{appointments.length === 0 ? (
<div className="flex flex-1 min-h-[160px] items-center justify-center rounded-[var(--radius-md)] border border-dashed border-border/50 bg-background-secondary/20 px-4">
<p className="text-sm text-text-muted text-center">{t('noUpcomingAppointments')}</p>
<div className="flex min-h-[72px] items-center justify-center rounded-[var(--radius-md)] border border-dashed border-border/50 bg-background-secondary/20 px-3">
<p className="text-xs text-text-muted text-center">{t('noUpcomingAppointments')}</p>
</div>
) : (
<ul className="divide-y divide-border/40">
@@ -73,26 +93,32 @@ export function TodayUpcomingAppointments({
const start = new Date(appointment.startAt);
const end = new Date(appointment.endAt);
const timeLabel = `${formatTimeForInput(start)} ${formatTimeForInput(end)}`;
const purposeIndex = treatmentCatalog.findIndex((entry) => entry.code === appointment.purpose);
const purposeTextColor = treatmentTypeColor(
appointment.purpose,
purposeIndex < 0 ? 0 : purposeIndex,
);
const purposeDisplay = purposeLabel(appointment.purpose, treatmentCatalog);
return (
<li key={appointment.id}>
<Link
href="/appointments"
className="flex items-center justify-between gap-3 py-3 -mx-2 px-2 rounded-[var(--radius-md)] hover:bg-background-secondary/45 transition-colors group"
href={treatmentAppointmentHref(appointment.id)}
className="group -mx-1 flex items-center justify-between gap-2 rounded-[var(--radius-md)] px-1 py-2 transition-colors hover:bg-background-secondary/45"
>
<div className="min-w-0">
<p className="text-sm font-medium text-text-primary truncate">
<p className="truncate text-xs font-medium text-text-primary">
{appointment.patientName}
</p>
<p className="text-xs text-text-muted mt-0.5 truncate">
<p className="mt-0.5 truncate text-[11px] text-text-muted">
{timeLabel}
{appointment.purpose ? (
<span className="text-text-secondary"> · {appointment.purpose}</span>
<span style={{ color: purposeTextColor }}> · {purposeDisplay}</span>
) : null}
</p>
</div>
<ChevronRight
className="h-4 w-4 shrink-0 text-text-muted opacity-0 group-hover:opacity-100 transition-opacity"
className="h-3.5 w-3.5 shrink-0 text-text-muted opacity-0 transition-opacity group-hover:opacity-100"
aria-hidden
/>
</Link>

View File

@@ -0,0 +1,34 @@
'use client';
import { useMemo } from 'react';
export function useTodayDayLabelFormatter() {
return useMemo(
() =>
new Intl.DateTimeFormat(undefined, {
weekday: 'short',
month: 'short',
day: 'numeric',
}),
[],
);
}
export function formatTodayChartDayLabel(
code: string,
formatter: Intl.DateTimeFormat,
): string {
const [year, month, day] = code.split('-').map(Number);
if (!year || !month || !day) return code;
return formatter.format(new Date(year, month - 1, day));
}
export function mapWeekChartBuckets<T extends { code: string; label: string }>(
buckets: T[],
formatter: Intl.DateTimeFormat,
): T[] {
return buckets.map((bucket) => ({
...bucket,
label: formatTodayChartDayLabel(bucket.code, formatter),
}));
}

View File

@@ -1,16 +1,24 @@
/** Bar fill colors aligned with the dark dashboard accent palette. */
export const TODAY_CHART_COLORS = [
'#00bcff',
'#e1bc72',
'#98d8b5',
'#cfb8f7',
'#f8c9a6',
'#f4b6b6',
'#abd8f3',
'#cbe9a1',
] as const;
import { CATALOG_PALETTE_COLORS } from '@/components/ui/treatment/catalog-type-colors';
/** Chart series colors — same palette as treatment / prosthesis catalog types. */
export const TODAY_CHART_COLORS = CATALOG_PALETTE_COLORS;
/** Primary accent for single-series charts (area, gauge). */
export const TODAY_CHART_PRIMARY_COLOR = CATALOG_PALETTE_COLORS[5] ?? '#c4b5fd';
/** Stacked bar segments for lab task activity. */
export const TODAY_CHART_COMPLETED_COLOR = CATALOG_PALETTE_COLORS[8] ?? '#86efac';
export const TODAY_CHART_RECEIVED_COLOR = CATALOG_PALETTE_COLORS[11] ?? '#bae6fd';
export const TODAY_CHART_AXIS_COLOR = '#8ea3bf';
export const TODAY_CHART_GRID_COLOR = 'rgba(41, 69, 106, 0.55)';
export const TODAY_CHART_TOOLTIP_BG = '#14253d';
export const TODAY_CHART_TOOLTIP_BORDER = '#29456a';
export const TODAY_CHART_TOOLTIP_STYLE = {
backgroundColor: TODAY_CHART_TOOLTIP_BG,
border: `1px solid ${TODAY_CHART_TOOLTIP_BORDER}`,
borderRadius: '6px',
color: '#f5f9ff',
fontSize: '12px',
} as const;

View File

@@ -125,6 +125,19 @@ export const TODAY_KPI_DEFINITIONS: TodayKpiDefinition[] = [
return count === null ? null : String(count);
},
},
{
key: 'providersWithoutWorkingHours',
titleKey: 'widgetProvidersWithoutWorkingHours',
icon: UserCog,
color: 'yellow',
orgTypes: ['CLINIC'],
href: '/staff',
isVisible: (org) => canViewStaff(org),
formatValue: (widgets) => {
const count = countWidget(widgets, 'providersWithoutWorkingHours');
return count === null ? null : String(count);
},
},
{
key: 'casesReceivedToday',
titleKey: 'widgetCasesReceivedToday',
@@ -138,6 +151,19 @@ export const TODAY_KPI_DEFINITIONS: TodayKpiDefinition[] = [
return count === null ? null : String(count);
},
},
{
key: 'casesInProgress',
titleKey: 'widgetCasesInProgress',
icon: FlaskConical,
color: 'yellow',
orgTypes: ['LAB'],
href: '/cases',
isVisible: (org) => canViewCases(org),
formatValue: (widgets) => {
const count = countWidget(widgets, 'casesInProgress');
return count === null ? null : String(count);
},
},
{
key: 'tasksInProgress',
titleKey: 'widgetTasksInProgress',

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,83 @@
/**
* Shared pastel palette for treatment types, prosthesis types, and dashboard charts.
* Treatment types own the canonical hex values; prosthesis types reuse the same codes.
*/
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',
};
/** Prosthesis codes mapped to treatment-palette hex values (mapping is arbitrary). */
export const PROSTHESIS_TYPE_COLORS: Record<string, string> = {
pfm_crown: '#cbd5e1',
pfz_crown: '#86efac',
monolithic_zirconia: '#99f6e4',
glass_ceramic_crown: '#fde68a',
full_metal_crown: '#cbd5e1',
temporary_resin_crown: '#bae6fd',
pmma: '#93c5fd',
peek_crown: '#99f6e4',
veneer_zirconia: '#86efac',
veneer_ips_press: '#fed7aa',
veneer_ips_cad: '#fdba74',
soft_structure: '#ddd6fe',
customized_abutment: '#a5b4fc',
prefabricated_abutment: '#93c5fd',
ti_base_abutment: '#bae6fd',
multi_unit_abutment: '#a5b4fc',
zirconia_abutment: '#86efac',
screw_retained: '#c4b5fd',
zirconia_overlay: '#99f6e4',
ips_overlay: '#fde68a',
smile_design: '#f9a8d4',
mockup: '#fbcfe8',
};
export const CATALOG_FALLBACK_COLORS = [
'#ddd6fe',
'#fed7aa',
'#fecaca',
'#bae6fd',
'#d9f99d',
'#fbcfe8',
] as const;
/** Ordered palette for charts and rotating unknown 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',
];
export function resolveCatalogTypeColor(
code: string,
colorMap: Record<string, string>,
index = 0,
): string {
return colorMap[code] ?? CATALOG_FALLBACK_COLORS[index % CATALOG_FALLBACK_COLORS.length];
}

View File

@@ -1,56 +1,21 @@
import type { CSSProperties } from 'react';
import {
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 the same hex palette as treatment types.
*
* 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);
}
/** 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). */

View File

@@ -4,6 +4,7 @@ import type { TodaySummaryResponse } from '@/types/today';
export interface TodaySummaryParams {
from: string;
to: string;
utcOffsetMinutes?: number;
}
export const todayApi = {

View File

@@ -33,7 +33,8 @@ export function useTodaySummary(organizationId?: string | null): UseTodaySummary
try {
const range = getLocalDayIsoRange(new Date());
const response = await todayApi.summary(range);
const utcOffsetMinutes = -new Date().getTimezoneOffset();
const response = await todayApi.summary({ ...range, utcOffsetMinutes });
setData(response.data);
} catch (err) {
setError(err as ApiError);

View File

@@ -16,9 +16,26 @@ export type TodayChartBucket = {
count: number;
};
export type TodayStackedDayBucket = {
code: string;
label: string;
completed: number;
received: number;
};
export type TodaySummaryCharts = {
treatmentMixWeek?: TodayChartBucket[];
tasksByWorkflowStep?: TodayChartBucket[];
appointmentsByProvider?: TodayChartBucket[];
caseCompletion?: {
completed: number;
total: number;
percent: number;
};
appointmentsWeekAll?: TodayChartBucket[];
appointmentsWeekMine?: TodayChartBucket[];
labTaskActivityWeek?: TodayStackedDayBucket[];
inProgressTasksByProsthesis?: TodayChartBucket[];
};
export type TodayWidgetKey =
@@ -28,11 +45,13 @@ export type TodayWidgetKey =
| 'draftTreatments'
| 'labCasesPendingSend'
| 'casesReceivedToday'
| 'casesInProgress'
| 'tasksInProgress'
| 'importantTasks'
| 'pendingConnections'
| 'seats'
| 'pendingStaffInvites';
| 'pendingStaffInvites'
| 'providersWithoutWorkingHours';
export type TodaySummaryWidgets = Partial<
Record<