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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user