improvement: some new gadgets added to dashboard. some new functionality added to existed gadgets.
This commit is contained in:
@@ -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')}
|
||||
|
||||
Reference in New Issue
Block a user