improvement: some new gadgets added to dashboard. some new functionality added to existed gadgets.
This commit is contained in:
@@ -209,6 +209,8 @@
|
||||
"widgetCasesInProgress": "Cases In Progress",
|
||||
"widgetTasksInProgress": "Tasks In Progress",
|
||||
"widgetImportantTasks": "Important Tasks",
|
||||
"widgetOverdueCases": "Overdue Cases",
|
||||
"widgetUnassignedTasks": "Unassigned Tasks",
|
||||
"widgetPendingConnections": "Pending Connections",
|
||||
"widgetProvidersWithoutWorkingHours": "Providers Without Working Hours",
|
||||
"widgetPendingStaffInvites": "Pending Staff Invites",
|
||||
@@ -243,6 +245,8 @@
|
||||
"chartTreatmentPlanCompletionRatio": "With treatment plan",
|
||||
"chartTasksByProsthesisTitle": "In-Progress Tasks by Prosthesis",
|
||||
"chartTasksByProsthesisSubtitle": "Current workload mix",
|
||||
"chartCasesDueWeekTitle": "Cases Due This Week",
|
||||
"chartCasesDueWeekSubtitle": "Active cases with a due date in the next 7 days",
|
||||
"chartCasePartnersClinicTitle": "Cases by Lab",
|
||||
"chartCasePartnersLabTitle": "Cases by Clinic",
|
||||
"chartCasePartnersSubtitle": "Last 30 days",
|
||||
@@ -444,8 +448,8 @@
|
||||
"assignedTo": "Assigned to {name}",
|
||||
"filterClinic": "Clinic",
|
||||
"filterClinicAll": "All clinics",
|
||||
"filterTreatmentType": "Treatment type",
|
||||
"filterTreatmentTypeAll": "All types",
|
||||
"filterProsthesisType": "Prosthesis type",
|
||||
"filterProsthesisTypeAll": "All types",
|
||||
"filterSentFrom": "Sent from",
|
||||
"filterSentTo": "Sent to",
|
||||
"clearFilters": "Clear filters",
|
||||
@@ -492,6 +496,7 @@
|
||||
"importantOnly": "Important first",
|
||||
"assignedToMe": "Assigned to me",
|
||||
"overdueOnly": "Overdue cases only",
|
||||
"unassignedOnly": "Unassigned only",
|
||||
"assignedToStaff": "Assigned to {name}",
|
||||
"resetView": "Reset filters & sort",
|
||||
"showInCase": "Show in case",
|
||||
|
||||
@@ -209,6 +209,8 @@
|
||||
"widgetCasesInProgress": "پروندههای در حال انجام",
|
||||
"widgetTasksInProgress": "وظایف در حال انجام",
|
||||
"widgetImportantTasks": "وظایف مهم",
|
||||
"widgetOverdueCases": "پروندههای معوق",
|
||||
"widgetUnassignedTasks": "وظایف بدون مسئول",
|
||||
"widgetPendingConnections": "درخواستهای اتصال در انتظار",
|
||||
"widgetProvidersWithoutWorkingHours": "ارائهدهندگان بدون ساعات کاری",
|
||||
"widgetPendingStaffInvites": "دعوتهای کارکنان در انتظار",
|
||||
@@ -243,6 +245,8 @@
|
||||
"chartTreatmentPlanCompletionRatio": "دارای طرح درمان",
|
||||
"chartTasksByProsthesisTitle": "وظایف در حال انجام بر اساس پروتز",
|
||||
"chartTasksByProsthesisSubtitle": "ترکیب بار کاری فعلی",
|
||||
"chartCasesDueWeekTitle": "پروندههای موعددار این هفته",
|
||||
"chartCasesDueWeekSubtitle": "پروندههای فعال با موعد تحویل در ۷ روز آینده",
|
||||
"chartCasePartnersClinicTitle": "کیسها بر اساس لابراتوار",
|
||||
"chartCasePartnersLabTitle": "کیسها بر اساس کلینیک",
|
||||
"chartCasePartnersSubtitle": "۳۰ روز گذشته",
|
||||
@@ -444,8 +448,8 @@
|
||||
"assignedTo": "واگذار شده به {name}",
|
||||
"filterClinic": "کلینیک",
|
||||
"filterClinicAll": "همه کلینیکها",
|
||||
"filterTreatmentType": "نوع درمان",
|
||||
"filterTreatmentTypeAll": "همه انواع",
|
||||
"filterProsthesisType": "نوع پروتز",
|
||||
"filterProsthesisTypeAll": "همه انواع",
|
||||
"filterSentFrom": "ارسال از",
|
||||
"filterSentTo": "ارسال تا",
|
||||
"clearFilters": "پاک کردن فیلترها",
|
||||
@@ -493,6 +497,7 @@
|
||||
"importantOnly": "مهمها در ابتدا",
|
||||
"assignedToMe": "واگذار شده به من",
|
||||
"overdueOnly": "فقط پروندههای عقبافتاده",
|
||||
"unassignedOnly": "فقط بدون مسئول",
|
||||
"assignedToStaff": "واگذار شده به {name}",
|
||||
"resetView": "بازنشانی فیلترها و مرتبسازی",
|
||||
"showInCase": "نمایش در پرونده",
|
||||
|
||||
@@ -209,6 +209,8 @@
|
||||
"widgetCasesInProgress": "Cases in uitvoering",
|
||||
"widgetTasksInProgress": "Taken in uitvoering",
|
||||
"widgetImportantTasks": "Belangrijke taken",
|
||||
"widgetOverdueCases": "Achterstallige cases",
|
||||
"widgetUnassignedTasks": "Niet-toegewezen taken",
|
||||
"widgetPendingConnections": "Openstaande koppelingsverzoeken",
|
||||
"widgetProvidersWithoutWorkingHours": "Behandelaars zonder werktijden",
|
||||
"widgetPendingStaffInvites": "Openstaande medewerkersuitnodigingen",
|
||||
@@ -243,6 +245,8 @@
|
||||
"chartTreatmentPlanCompletionRatio": "Met behandelplan",
|
||||
"chartTasksByProsthesisTitle": "Lopende taken per prothese",
|
||||
"chartTasksByProsthesisSubtitle": "Huidige werklastmix",
|
||||
"chartCasesDueWeekTitle": "Cases met deadline deze week",
|
||||
"chartCasesDueWeekSubtitle": "Actieve cases met een deadline in de komende 7 dagen",
|
||||
"chartCasePartnersClinicTitle": "Cases per lab",
|
||||
"chartCasePartnersLabTitle": "Cases per kliniek",
|
||||
"chartCasePartnersSubtitle": "Afgelopen 30 dagen",
|
||||
@@ -444,8 +448,8 @@
|
||||
"assignedTo": "Toegewezen aan {name}",
|
||||
"filterClinic": "Kliniek",
|
||||
"filterClinicAll": "Alle klinieken",
|
||||
"filterTreatmentType": "Behandeltype",
|
||||
"filterTreatmentTypeAll": "Alle types",
|
||||
"filterProsthesisType": "Prothesetype",
|
||||
"filterProsthesisTypeAll": "Alle types",
|
||||
"filterSentFrom": "Verzonden vanaf",
|
||||
"filterSentTo": "Verzonden tot",
|
||||
"clearFilters": "Filters wissen",
|
||||
@@ -493,6 +497,7 @@
|
||||
"importantOnly": "Belangrijke cases eerst",
|
||||
"assignedToMe": "Toegewezen aan mij",
|
||||
"overdueOnly": "Alleen te late cases",
|
||||
"unassignedOnly": "Alleen niet-toegewezen",
|
||||
"assignedToStaff": "Toegewezen aan {name}",
|
||||
"resetView": "Filters en sortering resetten",
|
||||
"showInCase": "In case tonen",
|
||||
|
||||
54
frontend/src/components/lab/parseTasksSearchParams.ts
Normal file
54
frontend/src/components/lab/parseTasksSearchParams.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { LabTaskStatus, TaskSortField } from '@/types/cases';
|
||||
import type { TasksViewState } from '@/components/lab/tasksViewDefaults';
|
||||
|
||||
/** Apply Tasks tab URL query params from Today deep links. */
|
||||
export function parseTasksSearchParams(
|
||||
searchParams: URLSearchParams,
|
||||
): Partial<TasksViewState> {
|
||||
const partial: Partial<TasksViewState> = {};
|
||||
|
||||
if (searchParams.get('importantOnly') === '1') {
|
||||
partial.importantOnly = true;
|
||||
}
|
||||
if (searchParams.get('overdueOnly') === '1') {
|
||||
partial.overdueOnly = true;
|
||||
}
|
||||
if (searchParams.get('unassignedOnly') === '1') {
|
||||
partial.unassignedOnly = true;
|
||||
}
|
||||
|
||||
const status = searchParams.get('status');
|
||||
if (status === 'IN_PROGRESS' || status === 'COMPLETED') {
|
||||
partial.statusFilter = status;
|
||||
} else if (status === 'all') {
|
||||
partial.statusFilter = '';
|
||||
}
|
||||
|
||||
const prosthesisTypeCode = searchParams.get('prosthesisTypeCode')?.trim();
|
||||
if (prosthesisTypeCode) {
|
||||
partial.prosthesisTypeCode = prosthesisTypeCode;
|
||||
partial.sortBy = 'prosthesis';
|
||||
partial.sortDir = 'desc';
|
||||
}
|
||||
|
||||
const sortBy = searchParams.get('sortBy');
|
||||
if (
|
||||
sortBy === 'date' ||
|
||||
sortBy === 'status' ||
|
||||
sortBy === 'clinic' ||
|
||||
sortBy === 'patient' ||
|
||||
sortBy === 'important' ||
|
||||
sortBy === 'prosthesis' ||
|
||||
sortBy === 'taskType' ||
|
||||
sortBy === 'dueDate'
|
||||
) {
|
||||
partial.sortBy = sortBy as TaskSortField;
|
||||
}
|
||||
|
||||
const sortDir = searchParams.get('sortDir');
|
||||
if (sortDir === 'asc' || sortDir === 'desc') {
|
||||
partial.sortDir = sortDir;
|
||||
}
|
||||
|
||||
return partial;
|
||||
}
|
||||
@@ -10,6 +10,8 @@ export type TasksViewState = {
|
||||
importantOnly: boolean;
|
||||
assignedToMe: boolean;
|
||||
overdueOnly: boolean;
|
||||
unassignedOnly: boolean;
|
||||
prosthesisTypeCode: string;
|
||||
page: number;
|
||||
highlightTaskId: string | null;
|
||||
};
|
||||
@@ -24,6 +26,8 @@ export const DEFAULT_TASKS_VIEW: TasksViewState = {
|
||||
importantOnly: false,
|
||||
assignedToMe: false,
|
||||
overdueOnly: false,
|
||||
unassignedOnly: false,
|
||||
prosthesisTypeCode: '',
|
||||
page: 1,
|
||||
highlightTaskId: null,
|
||||
};
|
||||
@@ -41,6 +45,8 @@ export function isDefaultTasksView(state: TasksViewState): boolean {
|
||||
state.importantOnly === DEFAULT_TASKS_VIEW.importantOnly &&
|
||||
state.assignedToMe === DEFAULT_TASKS_VIEW.assignedToMe &&
|
||||
state.overdueOnly === DEFAULT_TASKS_VIEW.overdueOnly &&
|
||||
state.unassignedOnly === DEFAULT_TASKS_VIEW.unassignedOnly &&
|
||||
state.prosthesisTypeCode === DEFAULT_TASKS_VIEW.prosthesisTypeCode &&
|
||||
state.page === DEFAULT_TASKS_VIEW.page &&
|
||||
state.highlightTaskId === DEFAULT_TASKS_VIEW.highlightTaskId
|
||||
);
|
||||
|
||||
20
frontend/src/components/staff/staffRowHighlight.ts
Normal file
20
frontend/src/components/staff/staffRowHighlight.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export const STAFF_ROW_HIGHLIGHT_CLASS =
|
||||
'relative bg-primary/10 ring-2 ring-inset ring-primary/50 shadow-[0_0_16px_rgba(99,102,241,0.2)]';
|
||||
|
||||
export function parseHighlightMembershipIds(searchParams: URLSearchParams): Set<string> {
|
||||
const raw = searchParams.get('highlightMembershipIds');
|
||||
if (!raw) return new Set();
|
||||
return new Set(
|
||||
raw
|
||||
.split(',')
|
||||
.map((id) => id.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
}
|
||||
|
||||
export function isStaffRowHighlighted(
|
||||
membershipId: string,
|
||||
highlightIds: Set<string>,
|
||||
): boolean {
|
||||
return highlightIds.has(membershipId);
|
||||
}
|
||||
28
frontend/src/components/today/today-deep-links.ts
Normal file
28
frontend/src/components/today/today-deep-links.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { TodayWidgetKey } from '@/types/today';
|
||||
|
||||
/** Deep links from Today dashboard gadgets into feature tabs. */
|
||||
export const todayDeepLinks = {
|
||||
tasksInProgress: '/tasks?status=IN_PROGRESS',
|
||||
importantTasks: '/tasks?importantOnly=1&status=IN_PROGRESS',
|
||||
overdueCases: '/tasks?overdueOnly=1&status=IN_PROGRESS',
|
||||
unassignedTasks: '/tasks?unassignedOnly=1&status=IN_PROGRESS',
|
||||
tasksByProsthesis: (prosthesisTypeCode: string) =>
|
||||
`/tasks?prosthesisTypeCode=${encodeURIComponent(prosthesisTypeCode)}&status=IN_PROGRESS&sortBy=prosthesis&sortDir=desc`,
|
||||
casesByClinic: (clinicOrganizationId: string) =>
|
||||
`/cases?clinicOrganizationId=${encodeURIComponent(clinicOrganizationId)}`,
|
||||
staffMissingWorkingHours: (membershipIds: string[]) => {
|
||||
if (membershipIds.length === 0) return '/staff';
|
||||
return `/staff?highlightMembershipIds=${membershipIds.map(encodeURIComponent).join(',')}`;
|
||||
},
|
||||
} as const;
|
||||
|
||||
const KPI_HREFS: Partial<Record<TodayWidgetKey, string>> = {
|
||||
tasksInProgress: todayDeepLinks.tasksInProgress,
|
||||
importantTasks: todayDeepLinks.importantTasks,
|
||||
overdueCases: todayDeepLinks.overdueCases,
|
||||
unassignedTasks: todayDeepLinks.unassignedTasks,
|
||||
};
|
||||
|
||||
export function todayKpiHref(key: TodayWidgetKey, fallback: string): string {
|
||||
return KPI_HREFS[key] ?? fallback;
|
||||
}
|
||||
@@ -36,6 +36,8 @@ export const TODAY_KPI_GADGET_FEATURE: Record<TodayWidgetKey, TodayGadgetFeature
|
||||
casesInProgress: 'cases',
|
||||
tasksInProgress: 'tasks',
|
||||
importantTasks: 'tasks',
|
||||
overdueCases: 'tasks',
|
||||
unassignedTasks: 'tasks',
|
||||
pendingConnections: 'organizations',
|
||||
pendingStaffInvites: 'staff',
|
||||
};
|
||||
@@ -53,6 +55,7 @@ export const TODAY_GADGET_ID_FEATURE: Record<string, TodayGadgetFeature> = {
|
||||
'chart-treatment-mix': 'treatment',
|
||||
'chart-lab-task-activity': 'cases',
|
||||
'chart-tasks-by-prosthesis': 'tasks',
|
||||
'chart-cases-due-week': 'cases',
|
||||
'chart-case-partners-month': 'treatment',
|
||||
};
|
||||
|
||||
|
||||
@@ -3,10 +3,12 @@ import {
|
||||
AlertCircle,
|
||||
CalendarDays,
|
||||
ClipboardList,
|
||||
Clock,
|
||||
FlaskConical,
|
||||
Link2,
|
||||
Stethoscope,
|
||||
UserCog,
|
||||
UserRound,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import type { Organization } from '@/types/organization';
|
||||
@@ -21,6 +23,7 @@ import {
|
||||
type OrgTypeName,
|
||||
} from '@/components/shared/permissions';
|
||||
import type { TodaySummaryWidgets, TodayWidgetKey } from '@/types/today';
|
||||
import { todayDeepLinks, todayKpiHref } from '@/components/today/today-deep-links';
|
||||
|
||||
export type KpiCardColor = 'blue' | 'yellow' | 'green' | 'red' | 'purple' | 'default';
|
||||
|
||||
@@ -31,6 +34,8 @@ export interface TodayKpiDefinition {
|
||||
color: KpiCardColor;
|
||||
orgTypes: OrgTypeName[];
|
||||
href: string;
|
||||
/** When set, overrides static href using live widget payload (e.g. membership IDs). */
|
||||
resolveHref?: (widgets: TodaySummaryWidgets) => string;
|
||||
isVisible: (org: Organization | null) => boolean;
|
||||
formatValue: (widgets: TodaySummaryWidgets) => string | null;
|
||||
formatSubtitle?: (widgets: TodaySummaryWidgets) => string | null;
|
||||
@@ -119,6 +124,13 @@ export const TODAY_KPI_DEFINITIONS: TodayKpiDefinition[] = [
|
||||
color: 'yellow',
|
||||
orgTypes: ['CLINIC'],
|
||||
href: '/staff',
|
||||
resolveHref: (widgets) => {
|
||||
const value = widgets.providersWithoutWorkingHours;
|
||||
if (!value || !('count' in value) || !value.membershipIds?.length) {
|
||||
return '/staff';
|
||||
}
|
||||
return todayDeepLinks.staffMissingWorkingHours(value.membershipIds);
|
||||
},
|
||||
isVisible: (org) => canViewStaff(org),
|
||||
formatValue: (widgets) => {
|
||||
const count = countWidget(widgets, 'providersWithoutWorkingHours');
|
||||
@@ -157,7 +169,7 @@ export const TODAY_KPI_DEFINITIONS: TodayKpiDefinition[] = [
|
||||
icon: ClipboardList,
|
||||
color: 'yellow',
|
||||
orgTypes: ['LAB'],
|
||||
href: '/tasks',
|
||||
href: todayKpiHref('tasksInProgress', '/tasks'),
|
||||
isVisible: (org) => canViewTasks(org),
|
||||
formatValue: (widgets) => {
|
||||
const count = countWidget(widgets, 'tasksInProgress');
|
||||
@@ -170,13 +182,39 @@ export const TODAY_KPI_DEFINITIONS: TodayKpiDefinition[] = [
|
||||
icon: AlertCircle,
|
||||
color: 'red',
|
||||
orgTypes: ['LAB'],
|
||||
href: '/tasks',
|
||||
href: todayKpiHref('importantTasks', '/tasks'),
|
||||
isVisible: (org) => canViewTasks(org),
|
||||
formatValue: (widgets) => {
|
||||
const count = countWidget(widgets, 'importantTasks');
|
||||
return count === null ? null : String(count);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'overdueCases',
|
||||
titleKey: 'widgetOverdueCases',
|
||||
icon: Clock,
|
||||
color: 'red',
|
||||
orgTypes: ['LAB'],
|
||||
href: todayKpiHref('overdueCases', '/tasks'),
|
||||
isVisible: (org) => canViewTasks(org),
|
||||
formatValue: (widgets) => {
|
||||
const count = countWidget(widgets, 'overdueCases');
|
||||
return count === null ? null : String(count);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'unassignedTasks',
|
||||
titleKey: 'widgetUnassignedTasks',
|
||||
icon: UserRound,
|
||||
color: 'purple',
|
||||
orgTypes: ['LAB'],
|
||||
href: todayKpiHref('unassignedTasks', '/tasks'),
|
||||
isVisible: (org) => canViewTasks(org),
|
||||
formatValue: (widgets) => {
|
||||
const count = countWidget(widgets, 'unassignedTasks');
|
||||
return count === null ? null : String(count);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'pendingConnections',
|
||||
titleKey: 'widgetPendingConnections',
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useAuth } from '@/lib/hooks/useAuth';
|
||||
import { useToast } from '@/lib/hooks/useToast';
|
||||
import { canEditCases, canEditTasks } from '@/components/shared/permissions';
|
||||
import { CaseDetailPanel, CaseTaskProgressBar } from '@/components/ui/lab/CaseDetailPanel';
|
||||
import { LabCaseProsthesisGroupsList } from '@/components/ui/lab/LabCaseProsthesisGroupsList';
|
||||
import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel';
|
||||
import {
|
||||
formatCaseDateTime,
|
||||
@@ -18,6 +19,7 @@ import { notificationsApi } from '@/lib/api/notifications';
|
||||
import { notifyTabBadgesChanged } from '@/lib/tabBadgeUtils';
|
||||
import { casesApi } from '@/lib/api/cases';
|
||||
import { tasksApi } from '@/lib/api/tasks';
|
||||
import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog';
|
||||
import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
|
||||
import { treatmentTypeLabelFromCatalog } from '@/components/shared/treatmentTypeDisplay';
|
||||
import { Badge } from '@/components/ui/shared/Badge';
|
||||
@@ -26,7 +28,7 @@ import { MobileDetailBackButton } from '@/components/ui/shared/MobileDetailBackB
|
||||
import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles';
|
||||
import { SearchBar } from '@/components/ui/shared/SearchBar';
|
||||
import { AppDateInput } from '@/components/ui/shared/AppDateInput';
|
||||
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
||||
import type { ProsthesisCatalogEntry, TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
||||
import type {
|
||||
AssignableTaskStaff,
|
||||
CasesFilterOptions,
|
||||
@@ -48,7 +50,7 @@ export function CasesPage() {
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [clinicId, setClinicId] = useState('');
|
||||
const [treatmentType, setTreatmentType] = useState('');
|
||||
const [prosthesisTypeCode, setProsthesisTypeCode] = useState('');
|
||||
const [sentFrom, setSentFrom] = useState('');
|
||||
const [sentTo, setSentTo] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
@@ -62,8 +64,9 @@ export function CasesPage() {
|
||||
});
|
||||
const [filterOptions, setFilterOptions] = useState<CasesFilterOptions>({
|
||||
clinics: [],
|
||||
treatmentTypes: [],
|
||||
prosthesisTypes: [],
|
||||
});
|
||||
const [prosthesisCatalog, setProsthesisCatalog] = useState<ProsthesisCatalogEntry[]>([]);
|
||||
const [treatmentCatalog, setTreatmentCatalog] = useState<TreatmentCatalogEntry[]>([]);
|
||||
|
||||
const [selectedCaseId, setSelectedCaseId] = useState<string | null>(null);
|
||||
@@ -80,7 +83,13 @@ export function CasesPage() {
|
||||
const canEditComments = canEditTasks(currentOrganization);
|
||||
const locale = user?.language ?? 'en';
|
||||
|
||||
const treatmentLabel = useCallback(
|
||||
const prosthesisLabel = useCallback(
|
||||
(code: string) =>
|
||||
prosthesisCatalog.find((entry) => entry.code === code)?.label ?? code,
|
||||
[prosthesisCatalog],
|
||||
);
|
||||
|
||||
const treatmentDetailLabel = useCallback(
|
||||
(type: string) => treatmentTypeLabelFromCatalog(type, treatmentCatalog),
|
||||
[treatmentCatalog],
|
||||
);
|
||||
@@ -94,13 +103,13 @@ export function CasesPage() {
|
||||
);
|
||||
|
||||
const hasActiveFilters = Boolean(
|
||||
search.trim() || clinicId || treatmentType || sentFrom || sentTo,
|
||||
search.trim() || clinicId || prosthesisTypeCode || sentFrom || sentTo,
|
||||
);
|
||||
|
||||
const loadCases = async (params: {
|
||||
q: string;
|
||||
clinicOrganizationId: string;
|
||||
treatmentType: string;
|
||||
prosthesisTypeCode: string;
|
||||
sentFrom: string;
|
||||
sentTo: string;
|
||||
page: number;
|
||||
@@ -111,7 +120,7 @@ export function CasesPage() {
|
||||
const response = await casesApi.list({
|
||||
q: params.q.trim() || undefined,
|
||||
clinicOrganizationId: params.clinicOrganizationId || undefined,
|
||||
treatmentType: params.treatmentType || undefined,
|
||||
prosthesisTypeCode: params.prosthesisTypeCode || undefined,
|
||||
sentFrom: params.sentFrom || undefined,
|
||||
sentTo: params.sentTo || undefined,
|
||||
page: params.page,
|
||||
@@ -148,6 +157,7 @@ export function CasesPage() {
|
||||
|
||||
useEffect(() => {
|
||||
void casesApi.listFilterOptions().then((r) => setFilterOptions(r.data)).catch(() => {});
|
||||
void prosthesisCatalogApi.list().then((r) => setProsthesisCatalog(r.data)).catch(() => {});
|
||||
void treatmentCatalogApi.list().then((r) => setTreatmentCatalog(r.data)).catch(() => {});
|
||||
if (canEdit) {
|
||||
void casesApi.listAssignableStaff().then((r) => setAssignableStaff(r.data)).catch(() => {});
|
||||
@@ -156,11 +166,6 @@ export function CasesPage() {
|
||||
}, [canEdit]);
|
||||
|
||||
useEffect(() => {
|
||||
const caseIdFromUrl = searchParams.get('caseId');
|
||||
if (caseIdFromUrl) {
|
||||
setSelectedCaseId(caseIdFromUrl);
|
||||
setMobileDetailOpen(true);
|
||||
}
|
||||
const clinicFromUrl = searchParams.get('clinicOrganizationId');
|
||||
if (clinicFromUrl) {
|
||||
setClinicId(clinicFromUrl);
|
||||
@@ -168,17 +173,37 @@ export function CasesPage() {
|
||||
}, [searchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedCaseId) {
|
||||
setMobileDetailOpen(false);
|
||||
if (loadingList) return;
|
||||
|
||||
if (cases.length === 0) {
|
||||
if (selectedCaseId !== null) {
|
||||
setSelectedCaseId(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}, [selectedCaseId]);
|
||||
|
||||
const urlCaseId = searchParams.get('caseId');
|
||||
if (urlCaseId && cases.some((item) => item.id === urlCaseId)) {
|
||||
if (selectedCaseId !== urlCaseId) {
|
||||
setSelectedCaseId(urlCaseId);
|
||||
setMobileDetailOpen(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedCaseId && cases.some((item) => item.id === selectedCaseId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedCaseId(cases[0].id);
|
||||
}, [cases, loadingList, searchParams, selectedCaseId]);
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
void loadCases({
|
||||
q: search,
|
||||
clinicOrganizationId: clinicId,
|
||||
treatmentType,
|
||||
prosthesisTypeCode,
|
||||
sentFrom,
|
||||
sentTo,
|
||||
page,
|
||||
@@ -186,7 +211,13 @@ export function CasesPage() {
|
||||
}, search ? 300 : 0);
|
||||
return () => clearTimeout(timeout);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- debounced search + filter reload
|
||||
}, [search, clinicId, treatmentType, sentFrom, sentTo, page]);
|
||||
}, [search, clinicId, prosthesisTypeCode, sentFrom, sentTo, page]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedCaseId) {
|
||||
setMobileDetailOpen(false);
|
||||
}
|
||||
}, [selectedCaseId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedCaseId) {
|
||||
@@ -222,7 +253,7 @@ export function CasesPage() {
|
||||
function clearFilters() {
|
||||
setSearch('');
|
||||
setClinicId('');
|
||||
setTreatmentType('');
|
||||
setProsthesisTypeCode('');
|
||||
setSentFrom('');
|
||||
setSentTo('');
|
||||
setPage(1);
|
||||
@@ -314,19 +345,19 @@ export function CasesPage() {
|
||||
</label>
|
||||
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs font-medium text-text-muted">{t('filterTreatmentType')}</span>
|
||||
<span className="text-xs font-medium text-text-muted">{t('filterProsthesisType')}</span>
|
||||
<select
|
||||
value={treatmentType}
|
||||
value={prosthesisTypeCode}
|
||||
onChange={(e) => {
|
||||
setTreatmentType(e.target.value);
|
||||
setProsthesisTypeCode(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className={filterSelectClass}
|
||||
>
|
||||
<option value="">{t('filterTreatmentTypeAll')}</option>
|
||||
{filterOptions.treatmentTypes.map((type) => (
|
||||
<option value="">{t('filterProsthesisTypeAll')}</option>
|
||||
{filterOptions.prosthesisTypes.map((type) => (
|
||||
<option key={type.code} value={type.code}>
|
||||
{treatmentLabel(type.code)}
|
||||
{prosthesisLabel(type.code)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -410,17 +441,17 @@ export function CasesPage() {
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="text-xs text-text-muted mt-0.5">
|
||||
{item.patient.mobile}
|
||||
</div>
|
||||
<div className="text-xs text-text-muted mt-0.5">{item.clinic.name}</div>
|
||||
<div className="text-xs text-text-muted mt-1">
|
||||
<div className="mt-1">
|
||||
<LabCaseProsthesisGroupsList
|
||||
groups={item.prosthesisGroups}
|
||||
prosthesisCatalog={prosthesisCatalog}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-[10px] text-text-muted mt-1">
|
||||
{formatCaseDateTime(item.sentAt, locale)}
|
||||
</div>
|
||||
<div className="text-xs text-text-muted mt-1 truncate">
|
||||
{item.treatmentType ? treatmentLabel(item.treatmentType) : '—'}
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<div className="mt-1.5">
|
||||
<CaseTaskProgressBar
|
||||
completed={item.taskProgress.completed}
|
||||
total={item.taskProgress.total}
|
||||
@@ -471,15 +502,15 @@ export function CasesPage() {
|
||||
{mobileDetailOpen && selectedCaseId ? (
|
||||
<MobileDetailBackButton onClick={() => setMobileDetailOpen(false)} />
|
||||
) : null}
|
||||
{!selectedCaseId ? (
|
||||
<p className="text-sm text-text-muted">{t('selectCaseHint')}</p>
|
||||
{!selectedCaseId && !loadingList && cases.length === 0 ? (
|
||||
<p className="text-sm text-text-muted">{t('emptyList')}</p>
|
||||
) : loadingDetail || !selectedCase ? (
|
||||
<p className="text-sm text-text-muted">{tCommon('loading')}</p>
|
||||
) : (
|
||||
<CaseDetailPanel
|
||||
labCase={selectedCase}
|
||||
locale={locale}
|
||||
treatmentLabel={treatmentLabel}
|
||||
treatmentLabel={treatmentDetailLabel}
|
||||
statusOptions={statusOptions}
|
||||
loadAttachmentBlob={loadCaseAttachmentBlob}
|
||||
showCommentsButton
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
formatToothList,
|
||||
prosthesisTypeColorFromCatalog,
|
||||
} from '@/components/treatment/prosthesisTypeDisplay';
|
||||
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
|
||||
|
||||
export type LabCaseProsthesisGroup = {
|
||||
prosthesisTypeCode: string;
|
||||
teeth: string[];
|
||||
};
|
||||
|
||||
interface LabCaseProsthesisGroupsListProps {
|
||||
groups: LabCaseProsthesisGroup[];
|
||||
prosthesisCatalog: readonly ProsthesisCatalogEntry[];
|
||||
fallbackTeeth?: string[];
|
||||
}
|
||||
|
||||
function prosthesisLabel(code: string, catalog: readonly ProsthesisCatalogEntry[]): string {
|
||||
return catalog.find((entry) => entry.code === code)?.label ?? code;
|
||||
}
|
||||
|
||||
export function LabCaseProsthesisGroupsList({
|
||||
groups,
|
||||
prosthesisCatalog,
|
||||
fallbackTeeth = [],
|
||||
}: LabCaseProsthesisGroupsListProps) {
|
||||
if (groups.length > 0) {
|
||||
return (
|
||||
<ul className="space-y-0.5">
|
||||
{groups.map((group) => (
|
||||
<li
|
||||
key={group.prosthesisTypeCode}
|
||||
className="text-[11px] leading-snug"
|
||||
style={{
|
||||
color: prosthesisTypeColorFromCatalog(group.prosthesisTypeCode, prosthesisCatalog),
|
||||
}}
|
||||
>
|
||||
<span className="font-medium">
|
||||
{prosthesisLabel(group.prosthesisTypeCode, prosthesisCatalog)}
|
||||
</span>
|
||||
{group.teeth.length > 0 ? (
|
||||
<span className="text-text-muted">
|
||||
{' · '}
|
||||
{formatToothList(group.teeth)}
|
||||
</span>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
if (fallbackTeeth.length > 0) {
|
||||
return <p className="text-[11px] text-text-muted">{formatToothList(fallbackTeeth)}</p>;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Button } from '@/components/ui/shared/Button';
|
||||
import { Checkbox } from '@/components/ui/shared/Checkbox';
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
isDefaultTasksView,
|
||||
TASK_COMPLETE_EXIT_MS,
|
||||
} from '@/components/lab/tasksViewDefaults';
|
||||
import { parseTasksSearchParams } from '@/components/lab/parseTasksSearchParams';
|
||||
import { useMarkTabReadOnVisit } from '@/lib/hooks/useTabBadgeCounts';
|
||||
import { notifyTabBadgesChanged } from '@/lib/tabBadgeUtils';
|
||||
import { scrollWithinMainScrollContainer } from '@/components/shared/scrollWithinMain';
|
||||
@@ -40,6 +42,7 @@ const PAGE_SIZE = 50;
|
||||
export function TasksPage() {
|
||||
const t = useTranslations('tasks');
|
||||
const tErrors = useTranslations('errors');
|
||||
const searchParams = useSearchParams();
|
||||
const { currentOrganization, user, isAuthReady } = useAuth();
|
||||
const { showError, showSuccess, setError } = useToast();
|
||||
|
||||
@@ -71,6 +74,10 @@ export function TasksPage() {
|
||||
const [importantOnly, setImportantOnly] = useState(DEFAULT_TASKS_VIEW.importantOnly);
|
||||
const [assignedToMe, setAssignedToMe] = useState(DEFAULT_TASKS_VIEW.assignedToMe);
|
||||
const [overdueOnly, setOverdueOnly] = useState(DEFAULT_TASKS_VIEW.overdueOnly);
|
||||
const [unassignedOnly, setUnassignedOnly] = useState(DEFAULT_TASKS_VIEW.unassignedOnly);
|
||||
const [prosthesisTypeCode, setProsthesisTypeCode] = useState(
|
||||
DEFAULT_TASKS_VIEW.prosthesisTypeCode,
|
||||
);
|
||||
const [sortBy, setSortBy] = useState<TaskSortField>(DEFAULT_TASKS_VIEW.sortBy);
|
||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>(DEFAULT_TASKS_VIEW.sortDir);
|
||||
const [highlightTaskId, setHighlightTaskId] = useState<string | null>(
|
||||
@@ -108,8 +115,10 @@ export function TasksPage() {
|
||||
if (importantOnly) params.pinImportant = true;
|
||||
if (assignedToMe) params.assignedToMe = true;
|
||||
if (overdueOnly) params.overdue = true;
|
||||
if (unassignedOnly) params.unassignedOnly = true;
|
||||
if (prosthesisTypeCode) params.prosthesisTypeCode = prosthesisTypeCode;
|
||||
return params;
|
||||
}, [page, search, clinicId, statusFilter, stepCompleted, importantOnly, assignedToMe, overdueOnly, sortBy, sortDir]);
|
||||
}, [page, search, clinicId, statusFilter, stepCompleted, importantOnly, assignedToMe, overdueOnly, unassignedOnly, prosthesisTypeCode, sortBy, sortDir]);
|
||||
|
||||
const displayModel = useMemo(() => groupTasksForDisplay(tasks, sortBy), [tasks, sortBy]);
|
||||
|
||||
@@ -126,6 +135,8 @@ export function TasksPage() {
|
||||
importantOnly,
|
||||
assignedToMe,
|
||||
overdueOnly,
|
||||
unassignedOnly,
|
||||
prosthesisTypeCode,
|
||||
page,
|
||||
highlightTaskId,
|
||||
}),
|
||||
@@ -139,6 +150,8 @@ export function TasksPage() {
|
||||
importantOnly,
|
||||
assignedToMe,
|
||||
overdueOnly,
|
||||
unassignedOnly,
|
||||
prosthesisTypeCode,
|
||||
page,
|
||||
highlightTaskId,
|
||||
],
|
||||
@@ -146,6 +159,18 @@ export function TasksPage() {
|
||||
|
||||
const showReset = !isDefaultTasksView(viewState);
|
||||
|
||||
useEffect(() => {
|
||||
const fromUrl = parseTasksSearchParams(searchParams);
|
||||
if (fromUrl.importantOnly !== undefined) setImportantOnly(fromUrl.importantOnly);
|
||||
if (fromUrl.overdueOnly !== undefined) setOverdueOnly(fromUrl.overdueOnly);
|
||||
if (fromUrl.unassignedOnly !== undefined) setUnassignedOnly(fromUrl.unassignedOnly);
|
||||
if (fromUrl.statusFilter !== undefined) setStatusFilter(fromUrl.statusFilter);
|
||||
if (fromUrl.prosthesisTypeCode !== undefined) setProsthesisTypeCode(fromUrl.prosthesisTypeCode);
|
||||
if (fromUrl.sortBy !== undefined) setSortBy(fromUrl.sortBy);
|
||||
if (fromUrl.sortDir !== undefined) setSortDir(fromUrl.sortDir);
|
||||
setPage(1);
|
||||
}, [searchParams]);
|
||||
|
||||
const loadTasks = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
@@ -205,6 +230,8 @@ export function TasksPage() {
|
||||
setImportantOnly(DEFAULT_TASKS_VIEW.importantOnly);
|
||||
setAssignedToMe(DEFAULT_TASKS_VIEW.assignedToMe);
|
||||
setOverdueOnly(DEFAULT_TASKS_VIEW.overdueOnly);
|
||||
setUnassignedOnly(DEFAULT_TASKS_VIEW.unassignedOnly);
|
||||
setProsthesisTypeCode(DEFAULT_TASKS_VIEW.prosthesisTypeCode);
|
||||
setSortBy(DEFAULT_TASKS_VIEW.sortBy);
|
||||
setSortDir(DEFAULT_TASKS_VIEW.sortDir);
|
||||
setPage(DEFAULT_TASKS_VIEW.page);
|
||||
@@ -223,6 +250,8 @@ export function TasksPage() {
|
||||
setImportantOnly(DEFAULT_TASKS_VIEW.importantOnly);
|
||||
setAssignedToMe(DEFAULT_TASKS_VIEW.assignedToMe);
|
||||
setOverdueOnly(DEFAULT_TASKS_VIEW.overdueOnly);
|
||||
setUnassignedOnly(DEFAULT_TASKS_VIEW.unassignedOnly);
|
||||
setProsthesisTypeCode(DEFAULT_TASKS_VIEW.prosthesisTypeCode);
|
||||
setSortBy(DEFAULT_TASKS_VIEW.sortBy);
|
||||
setSortDir(DEFAULT_TASKS_VIEW.sortDir);
|
||||
setExpandedCommentsTaskId(null);
|
||||
@@ -462,6 +491,12 @@ export function TasksPage() {
|
||||
label={t('overdueOnly')}
|
||||
className="text-xs [&_span:last-child]:text-xs"
|
||||
/>
|
||||
<Checkbox
|
||||
checked={unassignedOnly}
|
||||
onChange={(checked) => applyFilterChange(() => setUnassignedOnly(checked))}
|
||||
label={t('unassignedOnly')}
|
||||
className="text-xs [&_span:last-child]:text-xs"
|
||||
/>
|
||||
{showReset ? (
|
||||
<Button type="button" variant="ghost" size="sm" onClick={resetView}>
|
||||
{t('resetView')}
|
||||
|
||||
@@ -4,11 +4,13 @@ import { Check, Copy, Pencil, Trash2, UserCheck, UserX } from 'lucide-react';
|
||||
import type { StaffMemberDto } from '@/lib/api/staff';
|
||||
import { Badge } from '@/components/ui/shared/Badge';
|
||||
import { Card } from '@/components/ui/shared/Card';
|
||||
import { STAFF_ROW_HIGHLIGHT_CLASS } from '@/components/staff/staffRowHighlight';
|
||||
|
||||
type StaffMembersMobileListProps = {
|
||||
members: StaffMemberDto[];
|
||||
canEdit: boolean;
|
||||
organizationType: string | undefined;
|
||||
highlightMembershipIds?: Set<string>;
|
||||
copiedInviteMembershipId: string | null;
|
||||
copyingInviteMembershipId: string | null;
|
||||
enablingMembershipId: string | null;
|
||||
@@ -57,6 +59,7 @@ function memberStatusBadge(
|
||||
export function StaffMembersMobileList({
|
||||
members,
|
||||
canEdit,
|
||||
highlightMembershipIds,
|
||||
copiedInviteMembershipId,
|
||||
copyingInviteMembershipId,
|
||||
enablingMembershipId,
|
||||
@@ -74,9 +77,14 @@ export function StaffMembersMobileList({
|
||||
}: StaffMembersMobileListProps) {
|
||||
return (
|
||||
<ul className="space-y-3 lg:hidden">
|
||||
{members.map((member) => (
|
||||
<li key={member.id}>
|
||||
<Card padding="sm" className="space-y-3">
|
||||
{members.map((member) => {
|
||||
const highlighted = highlightMembershipIds?.has(member.id) ?? false;
|
||||
return (
|
||||
<li key={member.id} id={`staff-row-${member.id}`}>
|
||||
<Card
|
||||
padding="sm"
|
||||
className={highlighted ? `space-y-3 ${STAFF_ROW_HIGHLIGHT_CLASS}` : 'space-y-3'}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-text-primary truncate">{member.name}</p>
|
||||
@@ -184,7 +192,8 @@ export function StaffMembersMobileList({
|
||||
) : null}
|
||||
</Card>
|
||||
</li>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useRouter } from '@/i18n/navigation';
|
||||
import {
|
||||
@@ -37,6 +38,11 @@ import { Table } from '@/components/ui/shared/Table';
|
||||
import { getUserFacingError } from '@/components/shared/formatApiError';
|
||||
import { StaffMembersMobileList } from '@/components/ui/staff/StaffMembersMobileList';
|
||||
import { useToast } from '@/lib/hooks/useToast';
|
||||
import {
|
||||
parseHighlightMembershipIds,
|
||||
STAFF_ROW_HIGHLIGHT_CLASS,
|
||||
} from '@/components/staff/staffRowHighlight';
|
||||
import { scrollWithinMainScrollContainer } from '@/components/shared/scrollWithinMain';
|
||||
|
||||
type StoredInviteLink = {
|
||||
membershipId: string;
|
||||
@@ -145,6 +151,7 @@ function PermissionGrid({
|
||||
|
||||
export function StaffPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const t = useTranslations('staff');
|
||||
const tErrors = useTranslations('errors');
|
||||
const tCommon = useTranslations('common');
|
||||
@@ -199,6 +206,10 @@ export function StaffPage() {
|
||||
const [enablingMembershipId, setEnablingMembershipId] = useState<string | null>(null);
|
||||
|
||||
const canEdit = useMemo(() => canEditStaff(currentOrganization), [currentOrganization]);
|
||||
const highlightMembershipIds = useMemo(
|
||||
() => parseHighlightMembershipIds(searchParams),
|
||||
[searchParams],
|
||||
);
|
||||
const inviteHasTreatmentEdit = useMemo(
|
||||
() =>
|
||||
currentOrganization?.type === 'CLINIC' && featureStateHasTreatmentEdit(invitePerms),
|
||||
@@ -267,6 +278,18 @@ export function StaffPage() {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading || highlightMembershipIds.size === 0) return;
|
||||
const firstMatch = members.find((member) => highlightMembershipIds.has(member.id));
|
||||
if (!firstMatch) return;
|
||||
const frame = requestAnimationFrame(() => {
|
||||
scrollWithinMainScrollContainer(
|
||||
document.getElementById(`staff-row-${firstMatch.id}`),
|
||||
);
|
||||
});
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [loading, members, highlightMembershipIds]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentOrganization) return;
|
||||
if (!canViewStaff(currentOrganization)) {
|
||||
@@ -623,6 +646,7 @@ export function StaffPage() {
|
||||
members={members}
|
||||
canEdit={canEdit}
|
||||
organizationType={currentOrganization?.type}
|
||||
highlightMembershipIds={highlightMembershipIds}
|
||||
copiedInviteMembershipId={copiedInviteMembershipId}
|
||||
copyingInviteMembershipId={copyingInviteMembershipId}
|
||||
enablingMembershipId={enablingMembershipId}
|
||||
@@ -669,8 +693,19 @@ export function StaffPage() {
|
||||
}
|
||||
body={
|
||||
<>
|
||||
{members.map((m) => (
|
||||
<tr key={m.id} className="hover:bg-background-secondary/45">
|
||||
{members.map((m) => {
|
||||
const highlighted = highlightMembershipIds.has(m.id);
|
||||
return (
|
||||
<tr
|
||||
key={m.id}
|
||||
id={`staff-row-${m.id}`}
|
||||
className={[
|
||||
'hover:bg-background-secondary/45',
|
||||
highlighted ? STAFF_ROW_HIGHLIGHT_CLASS : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
<td className="text-sm text-text-primary">{m.name}</td>
|
||||
<td className="text-sm text-text-secondary">{m.email}</td>
|
||||
<td className="text-sm">
|
||||
@@ -794,7 +829,8 @@ export function StaffPage() {
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from 'recharts';
|
||||
import type { TodayChartBucket } from '@/types/today';
|
||||
import { TodayChartFrame } from '@/components/ui/today/TodayChartFrame';
|
||||
import { TodayBarChartTooltip } from '@/components/ui/today/TodayBarChartTooltip';
|
||||
import {
|
||||
TODAY_CHART_AXIS_COLOR,
|
||||
TODAY_CHART_COLORS,
|
||||
@@ -23,9 +24,10 @@ import {
|
||||
interface TodayBarChartProps {
|
||||
data: TodayChartBucket[];
|
||||
colorForCode?: (code: string, index: number) => string;
|
||||
onBarClick?: (bucket: TodayChartBucket) => void;
|
||||
}
|
||||
|
||||
export function TodayBarChart({ data, colorForCode }: TodayBarChartProps) {
|
||||
export function TodayBarChart({ data, colorForCode, onBarClick }: TodayBarChartProps) {
|
||||
const chartData = data.map((item) => ({
|
||||
...item,
|
||||
shortLabel: truncateLabel(item.label),
|
||||
@@ -83,17 +85,30 @@ export function TodayBarChart({ data, colorForCode }: TodayBarChartProps) {
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: 'rgba(0, 188, 255, 0.08)' }}
|
||||
contentStyle={{
|
||||
backgroundColor: TODAY_CHART_TOOLTIP_BG,
|
||||
border: `1px solid ${TODAY_CHART_TOOLTIP_BORDER}`,
|
||||
borderRadius: '6px',
|
||||
color: '#f5f9ff',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
labelFormatter={(_, payload) => {
|
||||
const row = payload?.[0]?.payload as TodayChartBucket | undefined;
|
||||
return row?.label ?? '';
|
||||
}}
|
||||
content={
|
||||
colorForCode ? (
|
||||
<TodayBarChartTooltip colorForCode={colorForCode} chartData={chartData} />
|
||||
) : undefined
|
||||
}
|
||||
contentStyle={
|
||||
colorForCode
|
||||
? undefined
|
||||
: {
|
||||
backgroundColor: TODAY_CHART_TOOLTIP_BG,
|
||||
border: `1px solid ${TODAY_CHART_TOOLTIP_BORDER}`,
|
||||
borderRadius: '6px',
|
||||
color: '#f5f9ff',
|
||||
fontSize: '12px',
|
||||
}
|
||||
}
|
||||
labelFormatter={
|
||||
colorForCode
|
||||
? undefined
|
||||
: (_, payload) => {
|
||||
const row = payload?.[0]?.payload as TodayChartBucket | undefined;
|
||||
return row?.label ?? '';
|
||||
}
|
||||
}
|
||||
/>
|
||||
<Bar dataKey="count" radius={[4, 4, 0, 0]} maxBarSize={48}>
|
||||
{chartData.map((entry, index) => (
|
||||
@@ -103,6 +118,8 @@ export function TodayBarChart({ data, colorForCode }: TodayBarChartProps) {
|
||||
colorForCode?.(entry.code, index) ??
|
||||
TODAY_CHART_COLORS[index % TODAY_CHART_COLORS.length]
|
||||
}
|
||||
className={onBarClick ? 'cursor-pointer' : undefined}
|
||||
onClick={() => onBarClick?.(entry)}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
|
||||
49
frontend/src/components/ui/today/TodayBarChartTooltip.tsx
Normal file
49
frontend/src/components/ui/today/TodayBarChartTooltip.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
'use client';
|
||||
|
||||
import type { TodayChartBucket } from '@/types/today';
|
||||
import {
|
||||
TODAY_CHART_TOOLTIP_BG,
|
||||
TODAY_CHART_TOOLTIP_BORDER,
|
||||
} from '@/components/today/chart-theme';
|
||||
|
||||
type TodayBarChartTooltipProps = {
|
||||
active?: boolean;
|
||||
payload?: ReadonlyArray<{ payload?: TodayChartBucket }>;
|
||||
colorForCode?: (code: string, index: number) => string;
|
||||
chartData: TodayChartBucket[];
|
||||
};
|
||||
|
||||
export function TodayBarChartTooltip({
|
||||
active,
|
||||
payload,
|
||||
colorForCode,
|
||||
chartData,
|
||||
}: TodayBarChartTooltipProps) {
|
||||
if (!active || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const row = payload[0]?.payload as TodayChartBucket | undefined;
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const index = chartData.findIndex((entry) => entry.code === row.code);
|
||||
const typeColor =
|
||||
colorForCode?.(row.code, index >= 0 ? index : 0) ?? '#f5f9ff';
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-md px-3 py-2 text-xs shadow-md"
|
||||
style={{
|
||||
backgroundColor: TODAY_CHART_TOOLTIP_BG,
|
||||
border: `1px solid ${TODAY_CHART_TOOLTIP_BORDER}`,
|
||||
}}
|
||||
>
|
||||
<p className="font-medium" style={{ color: typeColor }}>
|
||||
{row.label}
|
||||
</p>
|
||||
<p className="mt-0.5 font-semibold text-white">{row.count}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useRouter } from '@/i18n/navigation';
|
||||
import { useAuth } from '@/lib/hooks/useAuth';
|
||||
import {
|
||||
canEditCases,
|
||||
@@ -43,6 +44,7 @@ import {
|
||||
type TodayDashboardCell,
|
||||
} from '@/components/today/today-dashboard-layout';
|
||||
import { getEligibleTodayKpis, getVisibleTodayKpis } from '@/components/today/widget-registry';
|
||||
import { todayDeepLinks } from '@/components/today/today-deep-links';
|
||||
import { prosthesisTypeColorFromCatalog } from '@/components/treatment/prosthesisTypeDisplay';
|
||||
import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog';
|
||||
import { treatmentTypeColor } from '@/components/shared/treatmentTypeDisplay';
|
||||
@@ -75,6 +77,7 @@ export function TodayDashboard({
|
||||
hasError = false,
|
||||
}: TodayDashboardProps) {
|
||||
const t = useTranslations('today');
|
||||
const router = useRouter();
|
||||
const dayLabelFormatter = useTodayDayLabelFormatter();
|
||||
const { currentOrganization } = useAuth();
|
||||
const orgType = currentOrganization?.type;
|
||||
@@ -171,6 +174,15 @@ export function TodayDashboard({
|
||||
isOwner,
|
||||
currentOrganization,
|
||||
prosthesisCatalog,
|
||||
onTasksProsthesisClick: (code: string) => {
|
||||
router.push(todayDeepLinks.tasksByProsthesis(code));
|
||||
},
|
||||
onCasePartnerClick:
|
||||
orgType === 'LAB'
|
||||
? (code: string) => {
|
||||
router.push(todayDeepLinks.casesByClinic(code));
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}, [
|
||||
isInitialLoad,
|
||||
@@ -190,6 +202,7 @@ export function TodayDashboard({
|
||||
subscription,
|
||||
currentOrganization,
|
||||
prosthesisCatalog,
|
||||
router,
|
||||
]);
|
||||
|
||||
if (hasError && !loading && cells.length === 0) {
|
||||
@@ -311,6 +324,8 @@ function buildDashboardCells(options: {
|
||||
isOwner: boolean;
|
||||
currentOrganization: ReturnType<typeof useAuth>['currentOrganization'];
|
||||
prosthesisCatalog: ProsthesisCatalogEntry[];
|
||||
onTasksProsthesisClick?: (code: string) => void;
|
||||
onCasePartnerClick?: (code: string) => void;
|
||||
}): TodayDashboardCell[] {
|
||||
const cells: TodayDashboardCell[] = [];
|
||||
|
||||
@@ -333,6 +348,8 @@ function buildDashboardCells(options: {
|
||||
canEditCases(options.currentOrganization))),
|
||||
dayLabelFormatter: options.dayLabelFormatter,
|
||||
prosthesisCatalog: options.prosthesisCatalog,
|
||||
onTasksProsthesisClick: options.onTasksProsthesisClick,
|
||||
onCasePartnerClick: options.onCasePartnerClick,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -402,7 +419,7 @@ function buildDashboardCells(options: {
|
||||
subtitle={subtitle}
|
||||
icon={definition.icon}
|
||||
color={definition.color}
|
||||
href={definition.href}
|
||||
href={definition.resolveHref?.(options.widgets) ?? definition.href}
|
||||
className="h-full"
|
||||
/>
|
||||
),
|
||||
@@ -421,6 +438,8 @@ function buildChartCells(options: {
|
||||
showCasePartnersChart: boolean;
|
||||
dayLabelFormatter: ReturnType<typeof useTodayDayLabelFormatter>;
|
||||
prosthesisCatalog: ProsthesisCatalogEntry[];
|
||||
onTasksProsthesisClick?: (code: string) => void;
|
||||
onCasePartnerClick?: (code: string) => void;
|
||||
}): TodayDashboardCell[] {
|
||||
const { t, charts, orgType, isOwner, showMyAppointmentsWeekChart } = options;
|
||||
const cells: TodayDashboardCell[] = [];
|
||||
@@ -597,12 +616,38 @@ function buildChartCells(options: {
|
||||
<TodayBarChart
|
||||
data={tasksByProsthesisData}
|
||||
colorForCode={(code) => prosthesisTypeColorFromCatalog(code, options.prosthesisCatalog)}
|
||||
onBarClick={
|
||||
options.onTasksProsthesisClick
|
||||
? (bucket) => options.onTasksProsthesisClick?.(bucket.code)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</ChartCard>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
const casesDueWeekData = mapWeekChartBuckets(
|
||||
charts.casesDueWeek ?? [],
|
||||
options.dayLabelFormatter,
|
||||
);
|
||||
if (orgType === 'LAB' && charts.casesDueWeek !== undefined) {
|
||||
cells.push({
|
||||
id: 'chart-cases-due-week',
|
||||
layout: barChart,
|
||||
content: (
|
||||
<ChartCard
|
||||
title={t('chartCasesDueWeekTitle')}
|
||||
subtitle={t('chartCasesDueWeekSubtitle')}
|
||||
isEmpty={casesDueWeekData.every((row) => row.count === 0)}
|
||||
emptyMessage={t('chartEmpty')}
|
||||
>
|
||||
<TodayAreaChart data={casesDueWeekData} />
|
||||
</ChartCard>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
const casePartnersData = charts.casePartnersMonth ?? [];
|
||||
if (options.showCasePartnersChart && charts.casePartnersMonth !== undefined) {
|
||||
cells.push({
|
||||
@@ -629,6 +674,11 @@ function buildChartCells(options: {
|
||||
? t('chartCasePartnersSentLegend')
|
||||
: t('chartCasePartnersOpenLegend')
|
||||
}
|
||||
onPartnerClick={
|
||||
options.onCasePartnerClick
|
||||
? (partner) => options.onCasePartnerClick?.(partner.code)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</ChartCard>
|
||||
),
|
||||
@@ -657,6 +707,7 @@ function countVisibleCharts(
|
||||
if (orgType === 'LAB') {
|
||||
count += charts.labTaskActivityWeek !== undefined ? 1 : 0;
|
||||
count += charts.tasksByProsthesis !== undefined ? 1 : 0;
|
||||
count += charts.casesDueWeek !== undefined ? 1 : 0;
|
||||
count += showCasePartnersChart && charts.casePartnersMonth !== undefined ? 1 : 0;
|
||||
}
|
||||
if (
|
||||
|
||||
@@ -23,18 +23,26 @@ interface TodayPartnerCasesStackedBarChartProps {
|
||||
data: TodayPartnerCasesBucket[];
|
||||
completedLabel: string;
|
||||
pendingLabel: string;
|
||||
onPartnerClick?: (partner: TodayPartnerCasesBucket) => void;
|
||||
}
|
||||
|
||||
export function TodayPartnerCasesStackedBarChart({
|
||||
data,
|
||||
completedLabel,
|
||||
pendingLabel,
|
||||
onPartnerClick,
|
||||
}: TodayPartnerCasesStackedBarChartProps) {
|
||||
const chartData = data.map((item) => ({
|
||||
...item,
|
||||
shortLabel: truncateLabel(item.label),
|
||||
}));
|
||||
|
||||
const handlePartnerClick = (payload: { code?: string } | undefined) => {
|
||||
if (!onPartnerClick || !payload?.code) return;
|
||||
const partner = chartData.find((row) => row.code === payload.code);
|
||||
if (partner) onPartnerClick(partner);
|
||||
};
|
||||
|
||||
return (
|
||||
<TodayChartFrame>
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
@@ -71,6 +79,8 @@ export function TodayPartnerCasesStackedBarChart({
|
||||
fill={TODAY_CHART_COMPLETED_COLOR}
|
||||
radius={[0, 0, 0, 0]}
|
||||
maxBarSize={48}
|
||||
className={onPartnerClick ? 'cursor-pointer' : undefined}
|
||||
onClick={(payload) => handlePartnerClick(payload as { code?: string })}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="pending"
|
||||
@@ -79,6 +89,8 @@ export function TodayPartnerCasesStackedBarChart({
|
||||
fill={TODAY_CHART_RECEIVED_COLOR}
|
||||
radius={[4, 4, 0, 0]}
|
||||
maxBarSize={48}
|
||||
className={onPartnerClick ? 'cursor-pointer' : undefined}
|
||||
onClick={(payload) => handlePartnerClick(payload as { code?: string })}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
|
||||
@@ -2,12 +2,9 @@
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { CaseTaskProgressBar } from '@/components/ui/lab/CaseDetailPanel';
|
||||
import { LabCaseProsthesisGroupsList } from '@/components/ui/lab/LabCaseProsthesisGroupsList';
|
||||
import { LabCaseDueDateBadge } from '@/components/lab/LabCaseDueDateBadge';
|
||||
import { formatCaseDateTime } from '@/components/lab/caseDetailUtils';
|
||||
import {
|
||||
formatToothList,
|
||||
prosthesisTypeColorFromCatalog,
|
||||
} from '@/components/treatment/prosthesisTypeDisplay';
|
||||
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
|
||||
import type { PatientLabCaseSummary } from '@/types/lab-case-activity';
|
||||
|
||||
@@ -28,10 +25,6 @@ interface TreatmentLabCasesPanelProps {
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
function prosthesisLabel(code: string, catalog: ProsthesisCatalogEntry[]): string {
|
||||
return catalog.find((entry) => entry.code === code)?.label ?? code;
|
||||
}
|
||||
|
||||
export function TreatmentLabCasesPanel({
|
||||
scope,
|
||||
onScopeChange,
|
||||
@@ -138,33 +131,12 @@ export function TreatmentLabCasesPanel({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{item.prosthesisGroups.length > 0 ? (
|
||||
<ul className="space-y-0.5">
|
||||
{item.prosthesisGroups.map((group) => (
|
||||
<li
|
||||
key={group.prosthesisTypeCode}
|
||||
className="text-[11px] leading-snug"
|
||||
style={{
|
||||
color: prosthesisTypeColorFromCatalog(
|
||||
group.prosthesisTypeCode,
|
||||
prosthesisCatalog,
|
||||
),
|
||||
}}
|
||||
>
|
||||
<span className="font-medium">
|
||||
{prosthesisLabel(group.prosthesisTypeCode, prosthesisCatalog)}
|
||||
</span>
|
||||
{group.teeth.length > 0 ? (
|
||||
<span className="text-text-muted">
|
||||
{' · '}
|
||||
{formatToothList(group.teeth)}
|
||||
</span>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : item.teeth.length > 0 ? (
|
||||
<p className="text-[11px] text-text-muted">{formatToothList(item.teeth)}</p>
|
||||
{item.prosthesisGroups.length > 0 || item.teeth.length > 0 ? (
|
||||
<LabCaseProsthesisGroupsList
|
||||
groups={item.prosthesisGroups}
|
||||
prosthesisCatalog={prosthesisCatalog}
|
||||
fallbackTeeth={item.teeth}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<p className="text-[10px] text-text-muted">
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
export type LabTaskStatus = 'IN_PROGRESS' | 'COMPLETED';
|
||||
|
||||
export interface LabCaseProsthesisGroup {
|
||||
prosthesisTypeCode: string;
|
||||
teeth: string[];
|
||||
}
|
||||
|
||||
export interface LabCaseListItem {
|
||||
id: string;
|
||||
sentAt: string | null;
|
||||
@@ -14,7 +19,7 @@ export interface LabCaseListItem {
|
||||
lastName: string;
|
||||
mobile: string;
|
||||
};
|
||||
treatmentType: string | null;
|
||||
prosthesisGroups: LabCaseProsthesisGroup[];
|
||||
taskProgress: { completed: number; total: number };
|
||||
}
|
||||
|
||||
@@ -121,14 +126,14 @@ export interface ListLabCasesParams {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
clinicOrganizationId?: string;
|
||||
treatmentType?: string;
|
||||
prosthesisTypeCode?: string;
|
||||
sentFrom?: string;
|
||||
sentTo?: string;
|
||||
}
|
||||
|
||||
export interface CasesFilterOptions {
|
||||
clinics: Array<{ id: string; name: string }>;
|
||||
treatmentTypes: Array<{ code: string; labDependent: boolean }>;
|
||||
prosthesisTypes: Array<{ code: string }>;
|
||||
}
|
||||
|
||||
export interface PaginatedLabCases {
|
||||
@@ -160,6 +165,8 @@ export interface ListLabTasksParams {
|
||||
pinImportant?: boolean;
|
||||
assignedToMe?: boolean;
|
||||
overdue?: boolean;
|
||||
unassignedOnly?: boolean;
|
||||
prosthesisTypeCode?: string;
|
||||
sentFrom?: string;
|
||||
sentTo?: string;
|
||||
stepCompleted?: string;
|
||||
@@ -178,6 +185,9 @@ export interface LocateTaskPageParams {
|
||||
important?: boolean;
|
||||
pinImportant?: boolean;
|
||||
assignedToMe?: boolean;
|
||||
overdue?: boolean;
|
||||
unassignedOnly?: boolean;
|
||||
prosthesisTypeCode?: string;
|
||||
stepCompleted?: string;
|
||||
sortBy?: TaskSortField;
|
||||
sortDir?: 'asc' | 'desc';
|
||||
|
||||
@@ -46,6 +46,7 @@ export type TodaySummaryCharts = {
|
||||
appointmentsWeekMine?: TodayChartBucket[];
|
||||
labTaskActivityWeek?: TodayStackedDayBucket[];
|
||||
casePartnersMonth?: TodayPartnerCasesBucket[];
|
||||
casesDueWeek?: TodayChartBucket[];
|
||||
efficiencyReport?: TodayChartBucket[];
|
||||
};
|
||||
|
||||
@@ -58,6 +59,8 @@ export type TodayWidgetKey =
|
||||
| 'casesInProgress'
|
||||
| 'tasksInProgress'
|
||||
| 'importantTasks'
|
||||
| 'overdueCases'
|
||||
| 'unassignedTasks'
|
||||
| 'pendingConnections'
|
||||
| 'pendingStaffInvites'
|
||||
| 'providersWithoutWorkingHours';
|
||||
@@ -79,7 +82,7 @@ export type TodaySubscriptionSnapshot = {
|
||||
export type TodaySummaryWidgets = Partial<
|
||||
Record<
|
||||
TodayWidgetKey,
|
||||
| { count: number }
|
||||
| { count: number; membershipIds?: string[] }
|
||||
| { used: number; limit: number | null; unlimited: boolean }
|
||||
>
|
||||
>;
|
||||
|
||||
Reference in New Issue
Block a user