improvement: duedate added for shipped cases. cases and tasks ui and ux updated accordingly.

This commit is contained in:
2026-07-13 16:30:36 +03:30
parent a391eee15f
commit 08a1f34c4f
29 changed files with 502 additions and 30 deletions

View File

@@ -0,0 +1,29 @@
'use client';
import { Badge } from '@/components/ui/shared/Badge';
import {
dueDateBadgeVariantFromIso,
formatLabCaseDueDate,
} from '@/components/lab/labCaseDueDateDisplay';
interface LabCaseDueDateBadgeProps {
dueDate: string | null | undefined;
locale: string;
className?: string;
}
export function LabCaseDueDateBadge({ dueDate, locale, className }: LabCaseDueDateBadgeProps) {
const label = formatLabCaseDueDate(dueDate, locale);
if (!label) return null;
return (
<Badge
variant={dueDateBadgeVariantFromIso(dueDate)}
fixedWidth={false}
className={className}
title={label}
>
{label}
</Badge>
);
}

View File

@@ -0,0 +1,46 @@
import type { BadgeVariant } from '@/components/ui/shared/Badge';
export function toDateInputValue(iso: string | null | undefined): string {
if (!iso) return '';
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return '';
return date.toISOString().slice(0, 10);
}
export function startOfUtcDay(date = new Date()): Date {
const d = new Date(date);
d.setUTCHours(0, 0, 0, 0);
return d;
}
export function formatLabCaseDueDate(
iso: string | null | undefined,
locale: string,
): string | null {
if (!iso) return null;
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return null;
return new Intl.DateTimeFormat(locale, {
year: 'numeric',
month: 'short',
day: 'numeric',
}).format(date);
}
/** Whole calendar days from today (UTC) until due date. Negative = overdue. */
export function daysUntilDue(iso: string | null | undefined): number | null {
if (!iso) return null;
const due = new Date(iso);
if (Number.isNaN(due.getTime())) return null;
const msPerDay = 24 * 60 * 60 * 1000;
return Math.round((startOfUtcDay(due).getTime() - startOfUtcDay().getTime()) / msPerDay);
}
/** Green: >7 days · Yellow: 27 days · Red: ≤1 day (today, tomorrow, or overdue). */
export function dueDateBadgeVariantFromIso(iso: string | null | undefined): BadgeVariant {
const days = daysUntilDue(iso);
if (days === null) return 'default';
if (days <= 1) return 'danger';
if (days <= 7) return 'warning';
return 'success';
}

View File

@@ -14,6 +14,8 @@ export type CaseTaskGroup = {
clinic: LabTaskListItem['clinic'];
patient: LabTaskListItem['patient'];
caseSentAt: string | null;
caseDueDate: string | null;
isCaseOverdue: boolean;
isImportant: boolean;
prosthesisGroups: ProsthesisTaskGroup[];
};
@@ -47,6 +49,8 @@ export function groupTasksForDisplay(
clinic: task.clinic,
patient: task.patient,
caseSentAt: task.caseSentAt ?? null,
caseDueDate: task.caseDueDate ?? null,
isCaseOverdue: task.isCaseOverdue,
isImportant: task.isImportant,
prosthesisGroups: [],
});

View File

@@ -9,6 +9,7 @@ export type TasksViewState = {
sortDir: 'asc' | 'desc';
importantOnly: boolean;
assignedToMe: boolean;
overdueOnly: boolean;
page: number;
highlightTaskId: string | null;
};
@@ -22,6 +23,7 @@ export const DEFAULT_TASKS_VIEW: TasksViewState = {
sortDir: 'desc',
importantOnly: false,
assignedToMe: false,
overdueOnly: false,
page: 1,
highlightTaskId: null,
};
@@ -38,6 +40,7 @@ export function isDefaultTasksView(state: TasksViewState): boolean {
state.sortDir === DEFAULT_TASKS_VIEW.sortDir &&
state.importantOnly === DEFAULT_TASKS_VIEW.importantOnly &&
state.assignedToMe === DEFAULT_TASKS_VIEW.assignedToMe &&
state.overdueOnly === DEFAULT_TASKS_VIEW.overdueOnly &&
state.page === DEFAULT_TASKS_VIEW.page &&
state.highlightTaskId === DEFAULT_TASKS_VIEW.highlightTaskId
);

View File

@@ -11,6 +11,7 @@ import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles';
import { LabCaseAttachmentPreview } from '@/components/ui/lab/LabCaseAttachmentPreview';
import { LabCaseAttachmentsDialog } from '@/components/ui/lab/LabCaseAttachmentsDialog';
import { labTaskStatusVariant } from '@/components/lab/labTaskStatusDisplay';
import { LabCaseDueDateBadge } from '@/components/lab/LabCaseDueDateBadge';
import {
formatToothList,
prosthesisTypeBadgeStyleFromCatalog,
@@ -104,9 +105,12 @@ export function CaseDetailPanel({
<div className="space-y-4">
<header className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between border-b border-border pb-3">
<div className="min-w-0 flex-1 space-y-1">
<h2 className="text-lg font-semibold text-text-primary">
{formatPatientName(labCase.patient)}
</h2>
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-lg font-semibold text-text-primary">
{formatPatientName(labCase.patient)}
</h2>
<LabCaseDueDateBadge dueDate={labCase.dueDate} locale={locale} />
</div>
{!canEditImportant && labCase.isImportant ? (
<Badge variant="warning" fixedWidth={false} className="mt-1">
{t('importantLabel')}

View File

@@ -13,6 +13,7 @@ import {
formatCaseDateTime,
formatPatientName,
} from '@/components/lab/caseDetailUtils';
import { LabCaseDueDateBadge } from '@/components/lab/LabCaseDueDateBadge';
import { casesApi } from '@/lib/api/cases';
import { tasksApi } from '@/lib/api/tasks';
import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
@@ -376,6 +377,11 @@ export function CasesPage() {
<div className="font-medium text-text-primary">
{formatPatientName(item.patient)}
</div>
<LabCaseDueDateBadge
dueDate={item.dueDate}
locale={locale}
className="text-[10px]"
/>
{item.isImportant ? (
<Badge variant="warning" fixedWidth={false}>
{t('importantLabel')}

View File

@@ -2,6 +2,7 @@
import { useTranslations } from 'next-intl';
import { Badge } from '@/components/ui/shared/Badge';
import { LabCaseDueDateBadge } from '@/components/lab/LabCaseDueDateBadge';
import type { CaseTaskGroup } from '@/components/lab/taskListGrouping';
import { countCaseTaskProgress } from '@/components/lab/taskListGrouping';
@@ -34,6 +35,11 @@ export function TaskCaseGroupHeader({ caseGroup, locale }: TaskCaseGroupHeaderPr
{t('fromClinic', { name: caseGroup.clinic.name })} ·{' '}
{formatPatientName(caseGroup.patient)}
</p>
<LabCaseDueDateBadge
dueDate={caseGroup.caseDueDate}
locale={locale}
className="text-[10px]"
/>
{caseGroup.isImportant ? (
<Badge variant="warning" fixedWidth={false} className="text-[10px]">
{t('importantBadge')}

View File

@@ -11,6 +11,7 @@ import {
labTaskStatusSelectStyle,
labTaskStatusVariant,
} from '@/components/lab/labTaskStatusDisplay';
import { LabCaseDueDateBadge } from '@/components/lab/LabCaseDueDateBadge';
import {
formatToothList,
prosthesisTypeBadgeStyleFromCatalog,
@@ -101,6 +102,13 @@ export function TaskRow({
{t('importantBadge')}
</Badge>
) : null}
{flatMode && task.caseDueDate && !exiting ? (
<LabCaseDueDateBadge
dueDate={task.caseDueDate}
locale={locale}
className="text-[10px]"
/>
) : null}
</div>
{flatMode ? (
<p className="text-[11px] text-text-secondary truncate">

View File

@@ -68,6 +68,7 @@ export function TasksPage() {
const [stepCompleted, setStepCompleted] = useState(DEFAULT_TASKS_VIEW.stepCompleted);
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 [sortBy, setSortBy] = useState<TaskSortField>(DEFAULT_TASKS_VIEW.sortBy);
const [sortDir, setSortDir] = useState<'asc' | 'desc'>(DEFAULT_TASKS_VIEW.sortDir);
const [highlightTaskId, setHighlightTaskId] = useState<string | null>(
@@ -103,8 +104,9 @@ export function TasksPage() {
if (stepCompleted) params.stepCompleted = stepCompleted;
if (importantOnly) params.pinImportant = true;
if (assignedToMe) params.assignedToMe = true;
if (overdueOnly) params.overdue = true;
return params;
}, [page, search, clinicId, statusFilter, stepCompleted, importantOnly, assignedToMe, sortBy, sortDir]);
}, [page, search, clinicId, statusFilter, stepCompleted, importantOnly, assignedToMe, overdueOnly, sortBy, sortDir]);
const displayModel = useMemo(() => groupTasksForDisplay(tasks, sortBy), [tasks, sortBy]);
@@ -120,6 +122,7 @@ export function TasksPage() {
sortDir,
importantOnly,
assignedToMe,
overdueOnly,
page,
highlightTaskId,
}),
@@ -132,6 +135,7 @@ export function TasksPage() {
sortDir,
importantOnly,
assignedToMe,
overdueOnly,
page,
highlightTaskId,
],
@@ -197,6 +201,7 @@ export function TasksPage() {
setStepCompleted(DEFAULT_TASKS_VIEW.stepCompleted);
setImportantOnly(DEFAULT_TASKS_VIEW.importantOnly);
setAssignedToMe(DEFAULT_TASKS_VIEW.assignedToMe);
setOverdueOnly(DEFAULT_TASKS_VIEW.overdueOnly);
setSortBy(DEFAULT_TASKS_VIEW.sortBy);
setSortDir(DEFAULT_TASKS_VIEW.sortDir);
setPage(DEFAULT_TASKS_VIEW.page);
@@ -213,6 +218,8 @@ export function TasksPage() {
setStatusFilter(DEFAULT_TASKS_VIEW.statusFilter);
setStepCompleted(DEFAULT_TASKS_VIEW.stepCompleted);
setImportantOnly(DEFAULT_TASKS_VIEW.importantOnly);
setAssignedToMe(DEFAULT_TASKS_VIEW.assignedToMe);
setOverdueOnly(DEFAULT_TASKS_VIEW.overdueOnly);
setSortBy(DEFAULT_TASKS_VIEW.sortBy);
setSortDir(DEFAULT_TASKS_VIEW.sortDir);
setExpandedCommentsTaskId(null);
@@ -289,6 +296,8 @@ export function TasksPage() {
return 'groupingOffTaskType';
case 'status':
return 'groupingOffStatus';
case 'dueDate':
return 'groupingOffDueDate';
default:
return null;
}
@@ -410,6 +419,7 @@ export function TasksPage() {
className={`${filterSelectClass} min-w-0 flex-1`}
>
<option value="date">{t('sortDate')}</option>
<option value="dueDate">{t('sortDueDate')}</option>
<option value="clinic">{t('sortClinic')}</option>
<option value="patient">{t('sortPatient')}</option>
<option value="prosthesis">{t('sortProsthesis')}</option>
@@ -440,6 +450,12 @@ export function TasksPage() {
label={t('assignedToMe')}
className="text-xs [&_span:last-child]:text-xs"
/>
<Checkbox
checked={overdueOnly}
onChange={(checked) => applyFilterChange(() => setOverdueOnly(checked))}
label={t('overdueOnly')}
className="text-xs [&_span:last-child]:text-xs"
/>
{showReset ? (
<Button type="button" variant="ghost" size="sm" onClick={resetView}>
{t('resetView')}

View File

@@ -4,7 +4,8 @@ import { useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/shared/Button';
import { Checkbox } from '@/components/ui/shared/Checkbox';
import { isDetailReadyForLabDispatch } from '@/components/treatment/treatmentDetailRules';
import { isDetailReadyForLabDispatch, isLabCaseCompleted } from '@/components/treatment/treatmentDetailRules';
import { toDateInputValue } from '@/components/lab/labCaseDueDateDisplay';
import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles';
import { LinkedOrganizationSearchCombobox } from '@/components/ui/treatment/LinkedOrganizationSearchCombobox';
import { CaseSentLabel } from '@/components/ui/treatment/CaseSentLabel';
@@ -221,6 +222,73 @@ export function LabCasesDispatchPanel({
setApplyAllProsthesis('');
}
const caseFullyComplete = isLabCaseCompleted(activeLabCase?.taskProgress);
const canEditDueDate = canEdit && !disabled && (!sent || !caseFullyComplete);
async function handleSentDueDateBlur(nextValue: string) {
if (!activeLabCase?.id || !sent || !canEditDueDate) return;
const dueDate = nextValue || null;
if (dueDate === (activeLabCase.dueDate?.slice(0, 10) ?? null)) return;
try {
const response = await treatmentsApi.updateLabCaseDueDate(activeLabCase.id, dueDate);
updateActiveLabCase({
dueDate: response.data.dueDate,
taskProgress: response.data.taskProgress ?? activeLabCase.taskProgress,
});
} catch (error) {
onCommentError?.(error instanceof Error ? error.message : t('dueDateUpdateError'));
}
}
function renderShipmentCardHeader() {
return (
<div className="flex flex-col gap-3 border-b border-border/60 pb-3 sm:flex-row sm:items-start sm:justify-between">
<p className="text-xs font-semibold text-text-primary">{t('labShipmentIncludedDetails')}</p>
{renderDueDateField()}
</div>
);
}
function renderDueDateField() {
if (!activeLabCase) return null;
const inputValue = toDateInputValue(activeLabCase.dueDate);
return (
<label className="block shrink-0 sm:max-w-[11rem] sm:text-end">
<span className="block text-xs font-medium text-text-secondary sm:text-end">
{t('dueDateLabel')}{' '}
<span className="font-normal text-text-muted">({t('dueDateOptional')})</span>
</span>
<input
type="date"
value={inputValue}
disabled={!canEditDueDate}
onChange={(e) => {
if (!sent) {
updateActiveLabCase({ dueDate: e.target.value || null });
}
}}
onBlur={(e) => {
if (sent) void handleSentDueDateBlur(e.target.value);
}}
className={`${FORM_SELECT_CLASS} mt-2 w-full rounded-md px-2 py-1.5 text-sm`}
/>
{sent && caseFullyComplete && activeLabCase.dueDate ? (
<p className="mt-1.5 text-[11px] text-text-muted sm:text-end">{t('dueDateLockedCompleted')}</p>
) : null}
</label>
);
}
function renderIncludedDetailSummary() {
if (!activeDetail) return null;
return (
<p className="text-sm text-text-primary rounded-[var(--radius-sm)] border border-border/50 bg-background-secondary/50 px-3 py-2">
{detailSummary(activeDetail)}
</p>
);
}
const activeDetailAttachments = activeDetail.attachmentMetas ?? [];
return (
@@ -248,16 +316,10 @@ export function LabCasesDispatchPanel({
<p className="text-xs text-text-muted">{t('labDispatchEmpty')}</p>
) : activeLabCase ? (
<div className="space-y-4 border border-border/60 rounded-[var(--radius-md)] p-4 bg-background-secondary/30">
{renderShipmentCardHeader()}
{sent ? (
<>
<div>
<p className="text-xs font-medium text-text-secondary mb-2">
{t('labShipmentIncludedDetails')}
</p>
<p className="text-sm text-text-primary rounded-[var(--radius-sm)] border border-border/50 bg-background-secondary/50 px-3 py-2">
{detailSummary(activeDetail)}
</p>
</div>
{renderIncludedDetailSummary()}
{activeLabCase.id ? (
<LabCaseCommentsPanel
@@ -296,14 +358,7 @@ export function LabCasesDispatchPanel({
</>
) : (
<>
<div>
<p className="text-xs font-medium text-text-secondary mb-2">
{t('labShipmentIncludedDetails')}
</p>
<p className="text-sm text-text-primary rounded-[var(--radius-sm)] border border-border/50 bg-background-secondary/50 px-3 py-2">
{detailSummary(activeDetail)}
</p>
</div>
{renderIncludedDetailSummary()}
{!sent && activeDetailAttachments.length > 0 ? (
<div>

View File

@@ -203,6 +203,8 @@ function mapLabCaseDraftFromApi(lc: PastLabCase): LabCaseDraft {
attachmentIds: (lc.attachments ?? []).map((a) => a.id),
sentAt: lc.sentAt ?? null,
sends: lc.sends ?? [],
dueDate: lc.dueDate ?? null,
taskProgress: lc.taskProgress ?? null,
};
}
@@ -990,6 +992,7 @@ export function TreatmentWorkspace({
row !== null,
),
attachmentIds: lc.attachmentIds,
dueDate: lc.dueDate ?? null,
};
})
.filter((row): row is NonNullable<typeof row> => row !== null);

View File

@@ -83,6 +83,16 @@ export const treatmentsApi = {
return response.data;
},
updateLabCaseDueDate: async (
labCaseId: string,
dueDate: string | null,
): Promise<{ success: boolean; data: LabCaseResponse }> => {
const response = await apiClient.patch(`/treatments/lab-cases/${labCaseId}/due-date`, {
dueDate,
});
return response.data;
},
listLabCaseComments: async (
labCaseId: string,
): Promise<{ success: boolean; data: LabCaseComment[] }> => {

View File

@@ -3,6 +3,8 @@ export type LabTaskStatus = 'IN_PROGRESS' | 'COMPLETED';
export interface LabCaseListItem {
id: string;
sentAt: string | null;
dueDate: string | null;
isOverdue: boolean;
isImportant: boolean;
clinic: { id: string; name: string };
patient: {
@@ -79,6 +81,8 @@ export interface LabCaseAttachmentMeta {
export interface LabCaseDetail {
id: string;
sentAt: string | null;
dueDate: string | null;
isOverdue: boolean;
isImportant: boolean;
clinic: { id: string; name: string };
patient: {
@@ -143,7 +147,8 @@ export type TaskSortField =
| 'patient'
| 'important'
| 'prosthesis'
| 'taskType';
| 'taskType'
| 'dueDate';
export interface ListLabTasksParams {
q?: string;
@@ -153,6 +158,7 @@ export interface ListLabTasksParams {
important?: boolean;
pinImportant?: boolean;
assignedToMe?: boolean;
overdue?: boolean;
sentFrom?: string;
sentTo?: string;
stepCompleted?: string;
@@ -196,6 +202,8 @@ export interface LabTaskListItem {
stepLabel: string;
status: LabTaskStatus;
isImportant: boolean;
caseDueDate: string | null;
isCaseOverdue: boolean;
assignee: LabTaskUser | null;
assignedAt: string | null;
lastStatusChangedAt: string | null;

View File

@@ -109,6 +109,8 @@ export interface PastLabCase {
prosthesisTypeCode: string;
}>;
sends?: LabCaseSendInfo[];
dueDate?: string | null;
taskProgress?: LabCaseTaskProgress | null;
attachments?: TreatmentAttachmentMeta[];
}
@@ -155,6 +157,8 @@ export interface LabCaseDraft {
attachmentIds: string[];
sentAt?: string | null;
sends?: LabCaseSendInfo[];
dueDate?: string | null;
taskProgress?: LabCaseTaskProgress | null;
}
export type SavedTreatmentDetailPayload = {
@@ -180,6 +184,7 @@ export interface SaveLabCasePayload {
prosthesisTypeCode: string;
}>;
attachmentIds?: string[];
dueDate?: string | null;
}
export interface SaveTreatmentPayload {
@@ -201,6 +206,8 @@ export interface LabCaseResponse {
teeth: string[];
} | null;
sends: LabCaseSendInfo[];
dueDate: string | null;
taskProgress?: LabCaseTaskProgress | null;
toothProsthesis?: LabCaseToothProsthesisDraft[];
attachments?: TreatmentAttachmentMeta[];
}