improvement: users can now comment on a case and it's details and have an option to make it visible for clinics too.

This commit is contained in:
2026-07-07 17:14:31 +03:30
parent cb63ced4e3
commit b2d40b3e97
21 changed files with 467 additions and 269 deletions

View File

@@ -3,13 +3,17 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { MessageSquare } from 'lucide-react';
import { ToastStack } from '@/components/ui/shared/Toast';
import { formatApiErrorMessage } from '@/components/shared/formatApiError';
import { useAuth } from '@/lib/hooks/useAuth';
import { useToast } from '@/lib/hooks/useToast';
import { canEditCases } from '@/components/shared/permissions';
import { Badge, type BadgeVariant } from '@/components/ui/shared/Badge';
import { canEditCases, canEditTasks } from '@/components/shared/permissions';
import { Badge } from '@/components/ui/shared/Badge';
import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel';
import { labTaskStatusVariant } from '@/components/ui/lab/labTaskStatusDisplay';
import { casesApi } from '@/lib/api/cases';
import { tasksApi } from '@/lib/api/tasks';
import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
import { treatmentTypeLabelFromCatalog } from '@/components/ui/treatment/treatmentTypeDisplay';
import { Button } from '@/components/ui/shared/Button';
@@ -30,17 +34,6 @@ import {
const PAGE_SIZE = 20;
function taskStatusVariant(status: LabTaskStatus): BadgeVariant {
switch (status) {
case 'COMPLETED':
return 'success';
case 'IN_PROGRESS':
return 'default';
default:
return 'warning';
}
}
function formatPatientName(patient: { firstName: string; lastName: string }) {
return `${patient.firstName} ${patient.lastName}`.trim();
}
@@ -105,8 +98,10 @@ export default function CasesPage() {
const [loadingList, setLoadingList] = useState(false);
const [loadingDetail, setLoadingDetail] = useState(false);
const [updatingTaskId, setUpdatingTaskId] = useState<string | null>(null);
const [commentCount, setCommentCount] = useState(0);
const canEdit = canEditCases(currentOrganization);
const canEditComments = canEditTasks(currentOrganization);
const locale = user?.language ?? 'en';
const treatmentLabel = useCallback(
@@ -200,12 +195,21 @@ export default function CasesPage() {
useEffect(() => {
if (selectedCaseId) {
void loadDetail(selectedCaseId);
void tasksApi
.listComments(selectedCaseId)
.then((r) => setCommentCount(r.data.length))
.catch(() => setCommentCount(0));
} else {
setSelectedCase(null);
setCommentCount(0);
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- reload when selection changes
}, [selectedCaseId]);
function scrollToComments() {
document.getElementById('case-comments')?.scrollIntoView({ behavior: 'smooth' });
}
function clearFilters() {
setSearch('');
setClinicId('');
@@ -408,9 +412,17 @@ export default function CasesPage() {
) : (
<div className="space-y-4">
<header className="space-y-1 border-b border-border pb-3">
<h2 className="text-lg font-semibold text-text-primary">
{formatPatientName(selectedCase.patient)}
</h2>
<div className="flex flex-wrap items-start justify-between gap-2">
<h2 className="text-lg font-semibold text-text-primary">
{formatPatientName(selectedCase.patient)}
</h2>
<Button type="button" variant="outline" size="sm" onClick={scrollToComments}>
<MessageSquare className="h-4 w-4 me-1.5" />
{commentCount > 0
? t('commentsCount', { count: commentCount })
: t('showComments')}
</Button>
</div>
<p className="text-sm text-text-muted">
{t('patientMobile')}: {selectedCase.patient.mobile}
</p>
@@ -432,12 +444,6 @@ export default function CasesPage() {
total={selectedCase.taskProgress.total}
/>
</div>
{selectedCase.labComment ? (
<p className="text-sm text-text-muted pt-1">
<span className="font-medium text-text-primary">{t('labComment')}:</span>{' '}
{selectedCase.labComment}
</p>
) : null}
</header>
{selectedCase.details.length > 0 && (
@@ -493,7 +499,7 @@ export default function CasesPage() {
<span className="min-w-0 flex-1">
{task.stepOrder}. {task.stepLabel}
</span>
<Badge variant={taskStatusVariant(task.status)} fixedWidth={false}>
<Badge variant={labTaskStatusVariant(task.status)} fixedWidth={false}>
{statusOptions.find((opt) => opt.value === task.status)?.label ??
task.status}
</Badge>
@@ -530,6 +536,34 @@ export default function CasesPage() {
))
)}
</div>
{selectedCaseId ? (
<section id="case-comments" className="scroll-mt-4 border-t border-border pt-4">
<LabCaseCommentsPanel
caseId={selectedCaseId}
canPost={canEditComments}
canToggleVisibility={canEditComments}
loadComments={async () => {
const r = await tasksApi.listComments(selectedCaseId);
setCommentCount(r.data.length);
return r.data;
}}
onPost={async (body, visibleToClinic) => {
const r = await tasksApi.addComment(selectedCaseId, {
body,
visibleToClinic,
});
setCommentCount((n) => n + 1);
return r.data;
}}
onToggleVisibility={async (commentId, visible) => {
const r = await tasksApi.setCommentVisibility(commentId, visible);
return r.data;
}}
onError={toast.showError}
/>
</section>
) : null}
</div>
)}
</section>

View File

@@ -4,12 +4,15 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslations } from 'next-intl';
import { MessageSquare } from 'lucide-react';
import { ToastStack } from '@/components/ui/shared/Toast';
import { Badge, type BadgeVariant } from '@/components/ui/shared/Badge';
import { Badge } from '@/components/ui/shared/Badge';
import { Button } from '@/components/ui/shared/Button';
import { FORM_SELECT_CLASS } from '@/components/ui/shared/formSelectStyles';
import { SearchBar } from '@/components/ui/shared/SearchBar';
import { TreatmentTypeBadge } from '@/components/ui/treatment/TreatmentTypeBadge';
import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel';
import {
labTaskStatusSelectClass,
labTaskStatusVariant,
} from '@/components/ui/lab/labTaskStatusDisplay';
import {
formatToothList,
prosthesisTypeBadgeStyle,
@@ -19,8 +22,6 @@ import { canEditTasks, canViewTasks } from '@/components/shared/permissions';
import { useAuth } from '@/lib/hooks/useAuth';
import { useToast } from '@/lib/hooks/useToast';
import { tasksApi } from '@/lib/api/tasks';
import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
import { treatmentTypeLabelFromCatalog } from '@/components/ui/treatment/treatmentTypeDisplay';
import type {
LabTaskListItem,
LabTaskStatus,
@@ -28,14 +29,9 @@ import type {
PaginatedLabTasks,
TaskSortField,
} from '@/types/cases';
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
const PAGE_SIZE = 50;
function taskStatusVariant(status: LabTaskStatus): BadgeVariant {
return status === 'COMPLETED' ? 'success' : 'default';
}
function formatPatientName(patient: { firstName: string; lastName: string }) {
return `${patient.firstName} ${patient.lastName}`.trim();
}
@@ -55,7 +51,6 @@ export default function TasksPage() {
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [updatingTaskId, setUpdatingTaskId] = useState<string | null>(null);
const [treatmentCatalog, setTreatmentCatalog] = useState<TreatmentCatalogEntry[]>([]);
const [expandedCommentsCaseId, setExpandedCommentsCaseId] = useState<string | null>(null);
const [search, setSearch] = useState('');
@@ -127,10 +122,6 @@ export default function TasksPage() {
}
}, [listParams, showError, setError]);
useEffect(() => {
void treatmentCatalogApi.list().then((r) => setTreatmentCatalog(r.data)).catch(() => {});
}, []);
useEffect(() => {
if (!canView) return;
const timeout = setTimeout(() => void loadTasks(), search ? 300 : 0);
@@ -191,7 +182,7 @@ export default function TasksPage() {
}}
placeholder={t('searchPlaceholder')}
/>
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
<label className="space-y-1">
<span className="text-xs text-text-muted">{t('filterClinic')}</span>
<select
@@ -230,28 +221,28 @@ export default function TasksPage() {
</label>
<label className="space-y-1">
<span className="text-xs text-text-muted">{t('sortBy')}</span>
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as TaskSortField)}
className={filterSelectClass}
>
<option value="date">{t('sortDate')}</option>
<option value="status">{t('sortStatus')}</option>
<option value="clinic">{t('sortClinic')}</option>
<option value="patient">{t('sortPatient')}</option>
<option value="important">{t('sortImportant')}</option>
</select>
</label>
<label className="space-y-1">
<span className="text-xs text-text-muted"> </span>
<select
value={sortDir}
onChange={(e) => setSortDir(e.target.value as 'asc' | 'desc')}
className={filterSelectClass}
>
<option value="desc"></option>
<option value="asc"></option>
</select>
<div className="flex gap-1.5">
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as TaskSortField)}
className={`${filterSelectClass} min-w-0 flex-1`}
>
<option value="date">{t('sortDate')}</option>
<option value="status">{t('sortStatus')}</option>
<option value="clinic">{t('sortClinic')}</option>
<option value="patient">{t('sortPatient')}</option>
<option value="important">{t('sortImportant')}</option>
</select>
<select
value={sortDir}
onChange={(e) => setSortDir(e.target.value as 'asc' | 'desc')}
className={`${FORM_SELECT_CLASS} w-14 shrink-0 rounded-md px-2 py-1.5 text-sm`}
aria-label={t('sortDirection')}
>
<option value="desc"></option>
<option value="asc"></option>
</select>
</div>
</label>
</div>
<div className="flex flex-wrap items-center gap-4 text-sm">
@@ -303,12 +294,6 @@ export default function TasksPage() {
{t('importantBadge')}
</Badge>
) : null}
<span
className="inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium border"
style={prosthesisTypeBadgeStyle(task.prosthesisTypeCode, index)}
>
{task.prosthesisTypeLabel}
</span>
</div>
<p className="text-[11px] text-text-secondary truncate">
{t('fromClinic', { name: task.clinic.name })} ·{' '}
@@ -336,7 +321,7 @@ export default function TasksPage() {
onChange={(e) =>
void handleStatusUpdate(task.id, e.target.value as LabTaskStatus)
}
className={`${FORM_SELECT_CLASS} w-full max-w-[132px]`}
className={`${FORM_SELECT_CLASS} w-full max-w-[132px] ${labTaskStatusSelectClass(task.status)}`}
>
{statusOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
@@ -345,7 +330,7 @@ export default function TasksPage() {
))}
</select>
) : (
<Badge variant={taskStatusVariant(task.status)} fixedWidth={false}>
<Badge variant={labTaskStatusVariant(task.status)} fixedWidth={false}>
{statusOptions.find((opt) => opt.value === task.status)?.label ??
task.status}
</Badge>
@@ -369,10 +354,12 @@ export default function TasksPage() {
<MessageSquare className="h-4 w-4" />
</button>
) : null}
<TreatmentTypeBadge
type={task.treatmentType}
label={treatmentTypeLabelFromCatalog(task.treatmentType, treatmentCatalog)}
/>
<span
className="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium border"
style={prosthesisTypeBadgeStyle(task.prosthesisTypeCode, index)}
>
{task.prosthesisTypeLabel}
</span>
</div>
</div>