improvement: tasks and cases feature updated based on the new prosthesis types and their steps. the whole assignment proccess removed from the flow.

This commit is contained in:
2026-07-07 15:31:09 +03:30
parent ed7e7b1d8f
commit cb63ced4e3
35 changed files with 1819 additions and 454 deletions

View File

@@ -319,7 +319,7 @@
},
"cases": {
"title": "Cases",
"subtitle": "Lab cases sent from linked clinics. Assign tasks and track progress by tooth.",
"subtitle": "Lab cases sent from linked clinics. Track progress and flag important tasks.",
"searchPlaceholder": "Search by patient name or mobile…",
"emptyList": "No cases received yet.",
"selectCaseHint": "Select a case from the list to view tasks.",
@@ -329,13 +329,17 @@
"taskProgressShort": "{progress} tasks",
"treatmentDetails": "Treatment details",
"teethLabel": "Teeth",
"tasksByTooth": "Tasks by tooth",
"toothGroupTitle": "Tooth {tooth} · {prosthesis} · {type}",
"tasksByTooth": "Tasks",
"toothGroupTitle": "Teeth {teeth} · {prosthesis}",
"noTasks": "No tasks were generated for this case.",
"unassigned": "Unassigned",
"statusPending": "Pending",
"statusInProgress": "In progress",
"statusCompleted": "Completed",
"importantLabel": "Important",
"markImportant": "Mark as important",
"lastUpdatedBy": "Updated by {name}",
"lastUpdatedUnknown": "Not started yet",
"timelineTitle": "History",
"timelineEntry": "{status} · {name} · {date}",
"errorLoadList": "Failed to load cases.",
"errorLoadDetail": "Failed to load case details.",
"errorUpdateTask": "Failed to update task.",
@@ -351,32 +355,63 @@
"prevPage": "Previous",
"nextPage": "Next",
"pageSummary": "Page {page} of {totalPages} ({total} cases)",
"priorityLabel": "Priority",
"statusLabel": "Status"
},
"tasks": {
"title": "Tasks",
"subtitle": "Your assigned lab tasks. Update status as you work through each step.",
"subtitleOwner": "All lab tasks in the organization. Assign tasks from Cases; update status on your own assignments here.",
"subtitle": "All lab tasks from connected clinics. Filter, sort, and update the status of each step.",
"subtitleOwner": "All lab tasks from connected clinics. Filter, sort, and update the status of each step.",
"loading": "Loading tasks…",
"emptyList": "No tasks assigned to you yet.",
"emptyListOwner": "No tasks in the lab inbox yet.",
"emptyList": "No tasks match the current filters.",
"emptyListOwner": "No tasks match the current filters.",
"noPermissionTitle": "Tasks",
"noPermissionBody": "You do not have permission to view tasks for this organization.",
"fromClinic": "From {name}",
"patientLabel": "Patient",
"taskDate": "{date}",
"priorityLabel": "Priority {n}",
"toothLabel": "Tooth {tooth}",
"unassigned": "Unassigned",
"assignedTo": "Assigned to {name}",
"statusPending": "Pending",
"teethLabel": "Teeth {teeth}",
"importantBadge": "Important",
"lastUpdatedBy": "Updated by {name}",
"statusInProgress": "In progress",
"statusCompleted": "Completed",
"searchPlaceholder": "Search patient or clinic…",
"filterClinic": "Clinic",
"filterClinicAll": "All clinics",
"filterStatus": "Status",
"filterStatusAll": "All statuses",
"showCompleted": "Show completed",
"importantOnly": "Important only",
"filterSentFrom": "From",
"filterSentTo": "To",
"sortBy": "Sort by",
"sortDate": "Date",
"sortStatus": "Status",
"sortClinic": "Clinic",
"sortPatient": "Patient",
"sortImportant": "Important",
"clearFilters": "Clear filters",
"commentsButton": "Comments",
"errorLoadList": "Failed to load tasks.",
"errorUpdateTask": "Failed to update task.",
"pageSummary": "Page {page} of {totalPages} ({total} tasks)"
},
"caseComments": {
"title": "Comments",
"placeholder": "Write a comment…",
"reply": "Reply…",
"post": "Post",
"empty": "No comments yet.",
"visibleToClinicToggle": "Visible to clinic",
"clinicCanSee": "Clinic can see this",
"hiddenFromClinic": "Hidden from clinic",
"makeVisible": "Make visible to clinic",
"makeHidden": "Hide from clinic",
"labAuthor": "Lab",
"clinicAuthor": "Clinic",
"errorLoad": "Failed to load comments.",
"errorPost": "Failed to post comment.",
"errorToggle": "Failed to update comment visibility."
},
"appointments": {
"title": "Appointments",
"subtitle": "Search a patient, pick a date, then click a time slot under a provider to book.",

View File

@@ -319,7 +319,7 @@
},
"cases": {
"title": "پرونده‌ها",
"subtitle": "پرونده‌های ارسالی از کلینیک‌های متصل. وظایف را تخصیص دهید و پیشرفت هر دندان را پیگیری کنید.",
"subtitle": "پرونده‌های ارسالی از کلینیک‌های متصل. پیشرفت را پیگیری کنید و وظایف مهم را علامت بزنید.",
"searchPlaceholder": "جستجو با نام یا موبایل بیمار…",
"emptyList": "هنوز پرونده‌ای دریافت نشده است.",
"selectCaseHint": "برای مشاهده وظایف، یک پرونده از فهرست انتخاب کنید.",
@@ -329,13 +329,17 @@
"taskProgressShort": "{progress} وظیفه",
"treatmentDetails": "جزئیات درمان",
"teethLabel": "دندان‌ها",
"tasksByTooth": "وظایف به تفکیک دندان",
"toothGroupTitle": "دندان {tooth} · {prosthesis} · {type}",
"tasksByTooth": "وظایف",
"toothGroupTitle": "دندان‌های {teeth} · {prosthesis}",
"noTasks": "برای این پرونده وظیفه‌ای ایجاد نشده است.",
"unassigned": "بدون مسئول",
"statusPending": "در انتظار",
"statusInProgress": "در حال انجام",
"statusCompleted": "انجام شده",
"importantLabel": "مهم",
"markImportant": "علامت‌گذاری به عنوان مهم",
"lastUpdatedBy": "به‌روزرسانی توسط {name}",
"lastUpdatedUnknown": "هنوز شروع نشده",
"timelineTitle": "تاریخچه",
"timelineEntry": "{status} · {name} · {date}",
"errorLoadList": "بارگذاری پرونده‌ها ناموفق بود.",
"errorLoadDetail": "بارگذاری جزئیات پرونده ناموفق بود.",
"errorUpdateTask": "به‌روزرسانی وظیفه ناموفق بود.",
@@ -351,32 +355,63 @@
"prevPage": "قبلی",
"nextPage": "بعدی",
"pageSummary": "صفحه {page} از {totalPages} ({total} پرونده)",
"priorityLabel": "اولویت",
"statusLabel": "وضعیت"
},
"tasks": {
"title": "وظایف",
"subtitle": "وظایف لاب اختصاص‌یافته به شما. وضعیت را در حین انجام هر مرحله به‌روز کنید.",
"subtitleOwner": "همه وظایف لاب در سازمان. تخصیص از بخش پرونده‌ها؛ به‌روزرسانی وضعیت برای وظایف خودتان اینجا.",
"subtitle": "همه وظایف لاب از کلینیک‌های متصل. فیلتر، مرتب‌سازی و به‌روزرسانی وضعیت هر مرحله.",
"subtitleOwner": "همه وظایف لاب از کلینیک‌های متصل. فیلتر، مرتب‌سازی و به‌روزرسانی وضعیت هر مرحله.",
"loading": "در حال بارگذاری وظایف…",
"emptyList": "هنوز وظیفه‌ای به شما اختصاص داده نشده است.",
"emptyListOwner": "هنوز وظیفه‌ای در صندوق ورودی لاب وجود ندارد.",
"emptyList": "هیچ وظیفه‌ای با فیلترهای فعلی مطابقت ندارد.",
"emptyListOwner": "هیچ وظیفه‌ای با فیلترهای فعلی مطابقت ندارد.",
"noPermissionTitle": "وظایف",
"noPermissionBody": "شما مجوز مشاهده وظایف برای این سازمان را ندارید.",
"fromClinic": "از {name}",
"patientLabel": "بیمار",
"taskDate": "{date}",
"priorityLabel": "اولویت {n}",
"toothLabel": "دندان {tooth}",
"unassigned": "اختصاص داده نشده",
"assignedTo": "اختصاص به {name}",
"statusPending": "در انتظار",
"teethLabel": "دندان‌های {teeth}",
"importantBadge": "مهم",
"lastUpdatedBy": "به‌روزرسانی توسط {name}",
"statusInProgress": "در حال انجام",
"statusCompleted": "تکمیل‌شده",
"searchPlaceholder": "جستجوی بیمار یا کلینیک…",
"filterClinic": "کلینیک",
"filterClinicAll": "همه کلینیک‌ها",
"filterStatus": "وضعیت",
"filterStatusAll": "همه وضعیت‌ها",
"showCompleted": "نمایش تکمیل‌شده‌ها",
"importantOnly": "فقط مهم‌ها",
"filterSentFrom": "از",
"filterSentTo": "تا",
"sortBy": "مرتب‌سازی بر اساس",
"sortDate": "تاریخ",
"sortStatus": "وضعیت",
"sortClinic": "کلینیک",
"sortPatient": "بیمار",
"sortImportant": "مهم",
"clearFilters": "پاک کردن فیلترها",
"commentsButton": "نظرات",
"errorLoadList": "بارگذاری وظایف ناموفق بود.",
"errorUpdateTask": "به‌روزرسانی وظیفه ناموفق بود.",
"pageSummary": "صفحه {page} از {totalPages} ({total} وظیفه)"
},
"caseComments": {
"title": "نظرات",
"placeholder": "یک نظر بنویسید…",
"reply": "پاسخ…",
"post": "ثبت",
"empty": "هنوز نظری ثبت نشده است.",
"visibleToClinicToggle": "قابل مشاهده برای کلینیک",
"clinicCanSee": "کلینیک می‌تواند ببیند",
"hiddenFromClinic": "پنهان از کلینیک",
"makeVisible": "نمایش به کلینیک",
"makeHidden": "پنهان از کلینیک",
"labAuthor": "آزمایشگاه",
"clinicAuthor": "کلینیک",
"errorLoad": "بارگذاری نظرات ناموفق بود.",
"errorPost": "ثبت نظر ناموفق بود.",
"errorToggle": "به‌روزرسانی وضعیت نمایش نظر ناموفق بود."
},
"appointments": {
"title": "نوبت‌ها",
"subtitle": "یک بیمار را جستجو کنید، تاریخ را انتخاب کنید، سپس روی یک زمان در زیر ارائه‌دهنده کلیک کنید تا رزرو کنید.",

View File

@@ -319,7 +319,7 @@
},
"cases": {
"title": "Dossiers",
"subtitle": "Labdossiers van gekoppelde klinieken. Wijs taken toe en volg de voortgang per tand.",
"subtitle": "Labdossiers van gekoppelde klinieken. Volg de voortgang en markeer belangrijke taken.",
"searchPlaceholder": "Zoeken op patiëntnaam of mobiel…",
"emptyList": "Nog geen dossiers ontvangen.",
"selectCaseHint": "Selecteer een dossier uit de lijst om taken te bekijken.",
@@ -329,13 +329,17 @@
"taskProgressShort": "{progress} taken",
"treatmentDetails": "Behandeldetails",
"teethLabel": "Tanden",
"tasksByTooth": "Taken per tand",
"toothGroupTitle": "Tand {tooth} · {prosthesis} · {type}",
"tasksByTooth": "Taken",
"toothGroupTitle": "Tanden {teeth} · {prosthesis}",
"noTasks": "Er zijn geen taken gegenereerd voor dit dossier.",
"unassigned": "Niet toegewezen",
"statusPending": "In afwachting",
"statusInProgress": "Bezig",
"statusCompleted": "Voltooid",
"importantLabel": "Belangrijk",
"markImportant": "Markeren als belangrijk",
"lastUpdatedBy": "Bijgewerkt door {name}",
"lastUpdatedUnknown": "Nog niet gestart",
"timelineTitle": "Geschiedenis",
"timelineEntry": "{status} · {name} · {date}",
"errorLoadList": "Dossiers laden mislukt.",
"errorLoadDetail": "Dossierdetails laden mislukt.",
"errorUpdateTask": "Taak bijwerken mislukt.",
@@ -351,32 +355,63 @@
"prevPage": "Vorige",
"nextPage": "Volgende",
"pageSummary": "Pagina {page} van {totalPages} ({total} dossiers)",
"priorityLabel": "Prioriteit",
"statusLabel": "Status"
},
"tasks": {
"title": "Taken",
"subtitle": "Uw toegewezen labtaken. Werk de status bij terwijl u elke stap uitvoert.",
"subtitleOwner": "Alle labtaken in de organisatie. Wijs toe via Dossiers; werk hier de status bij voor uw eigen taken.",
"subtitle": "Alle labtaken van gekoppelde klinieken. Filter, sorteer en werk de status van elke stap bij.",
"subtitleOwner": "Alle labtaken van gekoppelde klinieken. Filter, sorteer en werk de status van elke stap bij.",
"loading": "Taken laden…",
"emptyList": "Nog geen taken aan u toegewezen.",
"emptyListOwner": "Nog geen taken in de lab-inbox.",
"emptyList": "Geen taken komen overeen met de huidige filters.",
"emptyListOwner": "Geen taken komen overeen met de huidige filters.",
"noPermissionTitle": "Taken",
"noPermissionBody": "U heeft geen toestemming om taken voor deze organisatie te bekijken.",
"fromClinic": "Van {name}",
"patientLabel": "Patiënt",
"taskDate": "{date}",
"priorityLabel": "Prioriteit {n}",
"toothLabel": "Tand {tooth}",
"unassigned": "Niet toegewezen",
"assignedTo": "Toegewezen aan {name}",
"statusPending": "In afwachting",
"teethLabel": "Tanden {teeth}",
"importantBadge": "Belangrijk",
"lastUpdatedBy": "Bijgewerkt door {name}",
"statusInProgress": "Bezig",
"statusCompleted": "Voltooid",
"searchPlaceholder": "Zoek patiënt of kliniek…",
"filterClinic": "Kliniek",
"filterClinicAll": "Alle klinieken",
"filterStatus": "Status",
"filterStatusAll": "Alle statussen",
"showCompleted": "Voltooide tonen",
"importantOnly": "Alleen belangrijk",
"filterSentFrom": "Vanaf",
"filterSentTo": "Tot",
"sortBy": "Sorteren op",
"sortDate": "Datum",
"sortStatus": "Status",
"sortClinic": "Kliniek",
"sortPatient": "Patiënt",
"sortImportant": "Belangrijk",
"clearFilters": "Filters wissen",
"commentsButton": "Opmerkingen",
"errorLoadList": "Taken laden mislukt.",
"errorUpdateTask": "Taak bijwerken mislukt.",
"pageSummary": "Pagina {page} van {totalPages} ({total} taken)"
},
"caseComments": {
"title": "Opmerkingen",
"placeholder": "Schrijf een opmerking…",
"reply": "Antwoorden…",
"post": "Plaatsen",
"empty": "Nog geen opmerkingen.",
"visibleToClinicToggle": "Zichtbaar voor kliniek",
"clinicCanSee": "Kliniek kan dit zien",
"hiddenFromClinic": "Verborgen voor kliniek",
"makeVisible": "Zichtbaar maken voor kliniek",
"makeHidden": "Verbergen voor kliniek",
"labAuthor": "Lab",
"clinicAuthor": "Kliniek",
"errorLoad": "Opmerkingen laden mislukt.",
"errorPost": "Opmerking plaatsen mislukt.",
"errorToggle": "Zichtbaarheid bijwerken mislukt."
},
"appointments": {
"title": "Afspraken",
"subtitle": "Zoek een patiënt, kies een datum en klik vervolgens op een tijdslot onder een aanbieder om te boeken.",

View File

@@ -17,16 +17,18 @@ import { FORM_SELECT_CLASS } from '@/components/ui/shared/formSelectStyles';
import { SearchBar } from '@/components/ui/shared/SearchBar';
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
import type {
AssignableMember,
CasesFilterOptions,
LabCaseDetail,
LabCaseListItem,
LabTaskStatus,
PaginatedLabCases,
} from '@/types/cases';
import {
formatToothList,
prosthesisTypeBadgeStyle,
} from '@/components/ui/treatment/prosthesisTypeDisplay';
const PAGE_SIZE = 20;
const PRIORITY_OPTIONS = [1, 2, 3, 4, 5] as const;
function taskStatusVariant(status: LabTaskStatus): BadgeVariant {
switch (status) {
@@ -100,7 +102,6 @@ export default function CasesPage() {
const [selectedCaseId, setSelectedCaseId] = useState<string | null>(null);
const [selectedCase, setSelectedCase] = useState<LabCaseDetail | null>(null);
const [members, setMembers] = useState<AssignableMember[]>([]);
const [loadingList, setLoadingList] = useState(false);
const [loadingDetail, setLoadingDetail] = useState(false);
const [updatingTaskId, setUpdatingTaskId] = useState<string | null>(null);
@@ -115,7 +116,6 @@ export default function CasesPage() {
const statusOptions: { value: LabTaskStatus; label: string }[] = useMemo(
() => [
{ value: 'PENDING', label: t('statusPending') },
{ value: 'IN_PROGRESS', label: t('statusInProgress') },
{ value: 'COMPLETED', label: t('statusCompleted') },
],
@@ -171,7 +171,6 @@ export default function CasesPage() {
useEffect(() => {
void casesApi.listFilterOptions().then((r) => setFilterOptions(r.data)).catch(() => {});
void casesApi.listAssignableMembers().then((r) => setMembers(r.data)).catch(() => {});
void treatmentCatalogApi.list().then((r) => setTreatmentCatalog(r.data)).catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only initial fetch
}, []);
@@ -216,25 +215,14 @@ export default function CasesPage() {
setPage(1);
}
async function handleTaskUpdate(
taskId: string,
payload: { assigneeUserId?: string | null; priority?: number },
) {
async function handleImportantToggle(taskId: string, isImportant: boolean) {
if (!selectedCaseId || !canEdit) return;
setUpdatingTaskId(taskId);
toast.setError('');
try {
await casesApi.updateTask(selectedCaseId, taskId, payload);
await casesApi.setTaskImportant(selectedCaseId, taskId, isImportant);
await loadDetail(selectedCaseId);
await loadCases({
q: search,
clinicOrganizationId: clinicId,
treatmentType,
sentFrom,
sentTo,
page,
});
} catch (error: unknown) {
toast.showError(formatApiErrorMessage(error, t('errorUpdateTask')));
} finally {
@@ -476,65 +464,65 @@ export default function CasesPage() {
{selectedCase.tasksByTooth.length === 0 ? (
<p className="text-sm text-text-muted">{t('noTasks')}</p>
) : (
selectedCase.tasksByTooth.map((group) => (
selectedCase.tasksByTooth.map((group, groupIndex) => (
<div
key={`${group.tooth}-${group.treatmentType}`}
key={`${group.treatmentDetailId}-${group.prosthesisTypeCode}`}
className="rounded-md border border-border p-3 space-y-2"
>
<div className="text-sm font-medium text-text-primary">
{t('toothGroupTitle', {
tooth: group.tooth,
prosthesis: group.prosthesisTypeLabel,
type: treatmentLabel(group.treatmentType),
})}
<div className="flex flex-wrap items-center gap-2">
<span
className="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium border"
style={prosthesisTypeBadgeStyle(group.prosthesisTypeCode, groupIndex)}
>
{group.prosthesisTypeLabel}
</span>
<span className="text-sm font-medium text-text-primary">
{t('toothGroupTitle', {
teeth: formatToothList(group.teeth),
prosthesis: group.prosthesisTypeLabel,
})}
</span>
</div>
<ul className="space-y-2">
{group.tasks.map((task) => (
<li
key={task.id}
className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_auto_88px_180px] items-center text-sm rounded bg-background p-2"
className="rounded bg-background p-2 text-sm space-y-1"
>
<span>
{task.stepOrder}. {task.stepLabel}
</span>
<Badge variant={taskStatusVariant(task.status)} fixedWidth={false}>
{statusOptions.find((opt) => opt.value === task.status)?.label ??
task.status}
</Badge>
<select
value={task.priority}
disabled={!canEdit || updatingTaskId === task.id}
onChange={(e) =>
void handleTaskUpdate(task.id, {
priority: Number(e.target.value),
})
}
className={FORM_SELECT_CLASS}
aria-label={t('priorityLabel')}
>
{PRIORITY_OPTIONS.map((value) => (
<option key={value} value={value}>
{value}
</option>
))}
</select>
<select
value={task.assigneeUserId ?? ''}
disabled={!canEdit || updatingTaskId === task.id}
onChange={(e) =>
void handleTaskUpdate(task.id, {
assigneeUserId: e.target.value || null,
})
}
className={FORM_SELECT_CLASS}
>
<option value="">{t('unassigned')}</option>
{members.map((member) => (
<option key={member.userId} value={member.userId}>
{member.name}
</option>
))}
</select>
<div className="flex flex-wrap items-center gap-2">
<span className="min-w-0 flex-1">
{task.stepOrder}. {task.stepLabel}
</span>
<Badge variant={taskStatusVariant(task.status)} fixedWidth={false}>
{statusOptions.find((opt) => opt.value === task.status)?.label ??
task.status}
</Badge>
{canEdit ? (
<label className="flex items-center gap-1.5 text-xs cursor-pointer shrink-0">
<input
type="checkbox"
checked={task.isImportant}
disabled={updatingTaskId === task.id}
onChange={(e) =>
void handleImportantToggle(task.id, e.target.checked)
}
/>
{t('importantLabel')}
</label>
) : task.isImportant ? (
<Badge variant="warning" fixedWidth={false}>
{t('importantLabel')}
</Badge>
) : null}
</div>
<p className="text-[11px] text-text-muted">
{task.lastStatusChangedBy
? t('lastUpdatedBy', { name: task.lastStatusChangedBy.name })
: t('lastUpdatedUnknown')}
{task.lastStatusChangedAt
? ` · ${formatDateTime(task.lastStatusChangedAt, locale)}`
: ''}
</p>
</li>
))}
</ul>

View File

@@ -1,12 +1,19 @@
'use client';
import { useEffect, useMemo, useRef, useState } from 'react';
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 { 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 {
formatToothList,
prosthesisTypeBadgeStyle,
} from '@/components/ui/treatment/prosthesisTypeDisplay';
import { formatApiErrorMessage } from '@/components/shared/formatApiError';
import { canEditTasks, canViewTasks } from '@/components/shared/permissions';
import { useAuth } from '@/lib/hooks/useAuth';
@@ -14,20 +21,19 @@ 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, PaginatedLabTasks } from '@/types/cases';
import type {
LabTaskListItem,
LabTaskStatus,
ListLabTasksParams,
PaginatedLabTasks,
TaskSortField,
} from '@/types/cases';
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
const PAGE_SIZE = 50;
function taskStatusVariant(status: LabTaskStatus): BadgeVariant {
switch (status) {
case 'COMPLETED':
return 'success';
case 'IN_PROGRESS':
return 'default';
default:
return 'warning';
}
return status === 'COMPLETED' ? 'success' : 'default';
}
function formatPatientName(patient: { firstName: string; lastName: string }) {
@@ -50,64 +56,94 @@ export default function TasksPage() {
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('');
const [clinicId, setClinicId] = useState('');
const [statusFilter, setStatusFilter] = useState<'' | LabTaskStatus>('');
const [showCompleted, setShowCompleted] = useState(false);
const [importantOnly, setImportantOnly] = useState(false);
const [sentFrom, setSentFrom] = useState('');
const [sentTo, setSentTo] = useState('');
const [sortBy, setSortBy] = useState<TaskSortField>('date');
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
const canView = canViewTasks(currentOrganization);
const canEdit = canEditTasks(currentOrganization);
const locale = user?.language ?? 'en';
const isOwner = Boolean(currentOrganization?.isOwner);
const tRef = useRef(t);
tRef.current = t;
const statusOptions: { value: LabTaskStatus; label: string }[] = useMemo(
() => [
{ value: 'PENDING', label: t('statusPending') },
{ value: 'IN_PROGRESS', label: t('statusInProgress') },
{ value: 'COMPLETED', label: t('statusCompleted') },
],
[t],
);
const listParams = useMemo((): ListLabTasksParams => {
const params: ListLabTasksParams = {
page,
limit: PAGE_SIZE,
sortBy,
sortDir,
};
if (search.trim()) params.q = search.trim();
if (clinicId) params.clinicOrganizationId = clinicId;
if (statusFilter) {
params.status = statusFilter;
} else if (showCompleted) {
params.completed = undefined;
} else {
params.completed = false;
}
if (importantOnly) params.important = true;
if (sentFrom) params.sentFrom = sentFrom;
if (sentTo) params.sentTo = sentTo;
return params;
}, [page, search, clinicId, statusFilter, showCompleted, importantOnly, sentFrom, sentTo, sortBy, sortDir]);
const clinicOptions = useMemo(() => {
const map = new Map<string, string>();
for (const task of tasks) {
map.set(task.clinic.id, task.clinic.name);
}
return [...map.entries()].map(([id, name]) => ({ id, name }));
}, [tasks]);
const loadTasks = useCallback(async () => {
setLoading(true);
setError('');
try {
const response = await tasksApi.list(listParams);
setTasks(response.data.items);
setPagination(response.data.pagination);
} catch (error: unknown) {
showError(formatApiErrorMessage(error, tRef.current('errorLoadList')));
} finally {
setLoading(false);
}
}, [listParams, showError, setError]);
useEffect(() => {
void treatmentCatalogApi.list().then((r) => setTreatmentCatalog(r.data)).catch(() => {});
}, []);
useEffect(() => {
if (!canView) return;
let cancelled = false;
void (async () => {
setLoading(true);
setError('');
try {
const response = await tasksApi.list({ page, limit: PAGE_SIZE });
if (cancelled) return;
setTasks(response.data.items);
setPagination(response.data.pagination);
} catch (error: unknown) {
if (cancelled) return;
showError(formatApiErrorMessage(error, tRef.current('errorLoadList')));
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [canView, page, showError, setError]);
const timeout = setTimeout(() => void loadTasks(), search ? 300 : 0);
return () => clearTimeout(timeout);
}, [canView, loadTasks, search]);
async function handleStatusUpdate(taskId: string, status: LabTaskStatus) {
if (!canEdit) return;
setUpdatingTaskId(taskId);
setError('');
try {
await tasksApi.updateStatus(taskId, status);
const response = await tasksApi.list({ page, limit: PAGE_SIZE });
setTasks(response.data.items);
setPagination(response.data.pagination);
await loadTasks();
} catch (error: unknown) {
showError(formatApiErrorMessage(error, t('errorUpdateTask')));
} finally {
@@ -123,9 +159,7 @@ export default function TasksPage() {
}).format(new Date(value));
}
function sortDateForTask(task: LabTaskListItem) {
return task.assignedAt ?? task.createdAt;
}
const filterSelectClass = `${FORM_SELECT_CLASS} w-full rounded-md px-2 py-1.5 text-sm`;
if (!isAuthReady) {
return <div className="text-sm text-text-muted">{t('loading')}</div>;
@@ -144,89 +178,229 @@ export default function TasksPage() {
<div className="space-y-4">
<header className="space-y-1">
<h1 className="text-2xl font-semibold text-text-primary">{t('title')}</h1>
<p className="text-sm text-text-secondary">
{isOwner ? t('subtitleOwner') : t('subtitle')}
</p>
<p className="text-sm text-text-secondary">{t('subtitle')}</p>
</header>
<section className="surface-card p-3 space-y-3">
<SearchBar
embedded
value={search}
onChange={(v) => {
setSearch(v);
setPage(1);
}}
placeholder={t('searchPlaceholder')}
/>
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
<label className="space-y-1">
<span className="text-xs text-text-muted">{t('filterClinic')}</span>
<select
value={clinicId}
onChange={(e) => {
setClinicId(e.target.value);
setPage(1);
}}
className={filterSelectClass}
>
<option value="">{t('filterClinicAll')}</option>
{clinicOptions.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</label>
<label className="space-y-1">
<span className="text-xs text-text-muted">{t('filterStatus')}</span>
<select
value={statusFilter}
onChange={(e) => {
setStatusFilter(e.target.value as '' | LabTaskStatus);
setPage(1);
}}
className={filterSelectClass}
>
<option value="">{t('filterStatusAll')}</option>
{statusOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</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>
</label>
</div>
<div className="flex flex-wrap items-center gap-4 text-sm">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={showCompleted}
onChange={(e) => {
setShowCompleted(e.target.checked);
setPage(1);
}}
/>
{t('showCompleted')}
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={importantOnly}
onChange={(e) => {
setImportantOnly(e.target.checked);
setPage(1);
}}
/>
{t('importantOnly')}
</label>
</div>
</section>
<section className="surface-card min-h-[280px]">
{loading && tasks.length === 0 ? (
<p className="p-3 text-sm text-text-muted">{t('loading')}</p>
) : tasks.length === 0 ? (
<p className="p-3 text-sm text-text-muted">
{isOwner ? t('emptyListOwner') : t('emptyList')}
</p>
<p className="p-3 text-sm text-text-muted">{t('emptyList')}</p>
) : (
<ul className="divide-y divide-border">
{tasks.map((task) => {
const statusEditable =
canEdit && (isOwner || task.assigneeUserId === user?.id);
{tasks.map((task, index) => {
const commentsOpen = expandedCommentsCaseId === task.labCaseId;
return (
<li
key={task.id}
className="grid grid-cols-[minmax(0,1fr)_132px_auto] items-center gap-x-3 gap-y-0.5 px-3 py-2"
>
<div className="min-w-0">
<p className="text-sm font-medium text-text-primary truncate">
{task.stepOrder}. {task.stepLabel}
</p>
<p className="text-[11px] text-text-secondary truncate">
{t('fromClinic', { name: task.clinic.name })} ·{' '}
{formatPatientName(task.patient)} · {t('toothLabel', { tooth: task.tooth })}
{task.prosthesisTypeLabel ? ` · ${task.prosthesisTypeLabel}` : ''}
</p>
<p className="text-[11px] text-text-muted truncate flex flex-wrap items-center gap-x-1 gap-y-0.5">
<span>{t('taskDate', { date: formatTaskDate(sortDateForTask(task)) })}</span>
{isOwner && (
<>
<span aria-hidden>·</span>
<Badge
variant={task.assignee ? 'success' : 'danger'}
fixedWidth={false}
>
{task.assignee
? t('assignedTo', { name: task.assignee.name })
: t('unassigned')}
<li key={task.id}>
<div className="grid grid-cols-[minmax(0,1fr)_132px_auto] items-center gap-x-3 gap-y-0.5 px-3 py-2">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-1.5">
<p className="text-sm font-medium text-text-primary">
{task.stepOrder}. {task.stepLabel}
</p>
{task.isImportant ? (
<Badge variant="warning" fixedWidth={false}>
{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 })} ·{' '}
{formatPatientName(task.patient)} ·{' '}
{t('teethLabel', { teeth: formatToothList(task.teeth) })}
</p>
<p className="text-[11px] text-text-muted truncate">
<span>{t('taskDate', { date: formatTaskDate(task.createdAt) })}</span>
{task.lastStatusChangedBy ? (
<>
<span aria-hidden> · </span>
<span>
{t('lastUpdatedBy', { name: task.lastStatusChangedBy.name })}
</span>
</>
) : null}
</p>
</div>
<div className="flex justify-center">
{canEdit ? (
<select
value={task.status}
disabled={updatingTaskId === task.id}
onChange={(e) =>
void handleStatusUpdate(task.id, e.target.value as LabTaskStatus)
}
className={`${FORM_SELECT_CLASS} w-full max-w-[132px]`}
>
{statusOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
) : (
<Badge variant={taskStatusVariant(task.status)} fixedWidth={false}>
{statusOptions.find((opt) => opt.value === task.status)?.label ??
task.status}
</Badge>
)}
</p>
</div>
<div className="flex items-center gap-1.5 shrink-0 justify-end">
{canEdit ? (
<button
type="button"
onClick={() =>
setExpandedCommentsCaseId(commentsOpen ? null : task.labCaseId)
}
className={`p-1.5 rounded border ${
commentsOpen
? 'border-primary bg-primary/10 text-primary'
: 'border-border text-text-muted hover:border-primary/40'
}`}
title={t('commentsButton')}
>
<MessageSquare className="h-4 w-4" />
</button>
) : null}
<TreatmentTypeBadge
type={task.treatmentType}
label={treatmentTypeLabelFromCatalog(task.treatmentType, treatmentCatalog)}
/>
</div>
</div>
<div className="flex justify-center">
{statusEditable ? (
<select
value={task.status}
disabled={updatingTaskId === task.id}
onChange={(e) =>
void handleStatusUpdate(task.id, e.target.value as LabTaskStatus)
}
className={`${FORM_SELECT_CLASS} w-full max-w-[132px]`}
>
{statusOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
) : (
<Badge variant={taskStatusVariant(task.status)} fixedWidth={false}>
{statusOptions.find((opt) => opt.value === task.status)?.label ??
task.status}
</Badge>
)}
</div>
<div className="flex items-center gap-1.5 shrink-0 justify-end">
<Badge variant="default" fixedWidth={false}>
{t('priorityLabel', { n: task.priority })}
</Badge>
<TreatmentTypeBadge
type={task.treatmentType}
label={treatmentTypeLabelFromCatalog(task.treatmentType, treatmentCatalog)}
/>
</div>
{commentsOpen && canEdit ? (
<div className="px-3 pb-3 border-t border-border/50">
<LabCaseCommentsPanel
caseId={task.labCaseId}
canPost
canToggleVisibility
loadComments={async () => {
const r = await tasksApi.listComments(task.labCaseId);
return r.data;
}}
onPost={async (body, visibleToClinic) => {
const r = await tasksApi.addComment(task.labCaseId, {
body,
visibleToClinic,
});
return r.data;
}}
onToggleVisibility={async (commentId, visible) => {
const r = await tasksApi.setCommentVisibility(commentId, visible);
return r.data;
}}
onError={showError}
/>
</div>
) : null}
</li>
);
})}

View File

@@ -0,0 +1,164 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { Eye, EyeOff } from 'lucide-react';
import { formatApiErrorMessage } from '@/components/shared/formatApiError';
import { Button } from '@/components/ui/shared/Button';
import type { LabCaseComment } from '@/types/cases';
interface LabCaseCommentsPanelProps {
caseId: string;
canPost: boolean;
canToggleVisibility: boolean;
loadComments: () => Promise<LabCaseComment[]>;
onPost: (body: string, visibleToClinic?: boolean) => Promise<LabCaseComment>;
onToggleVisibility?: (commentId: string, visible: boolean) => Promise<LabCaseComment>;
onError?: (message: string) => void;
}
export function LabCaseCommentsPanel({
caseId,
canPost,
canToggleVisibility,
loadComments,
onPost,
onToggleVisibility,
onError,
}: LabCaseCommentsPanelProps) {
const t = useTranslations('caseComments');
const [comments, setComments] = useState<LabCaseComment[]>([]);
const [loading, setLoading] = useState(false);
const [posting, setPosting] = useState(false);
const [body, setBody] = useState('');
const [visibleToClinic, setVisibleToClinic] = useState(false);
const refresh = useCallback(async () => {
setLoading(true);
try {
const items = await loadComments();
setComments(items);
} catch (error: unknown) {
onError?.(formatApiErrorMessage(error, t('errorLoad')));
} finally {
setLoading(false);
}
}, [loadComments, onError, t]);
useEffect(() => {
void refresh();
}, [caseId, refresh]);
async function handlePost() {
const trimmed = body.trim();
if (!trimmed || !canPost) return;
setPosting(true);
try {
const created = await onPost(trimmed, visibleToClinic);
setComments((prev) => [...prev, created]);
setBody('');
setVisibleToClinic(false);
} catch (error: unknown) {
onError?.(formatApiErrorMessage(error, t('errorPost')));
} finally {
setPosting(false);
}
}
async function handleToggle(comment: LabCaseComment) {
if (!onToggleVisibility || !canToggleVisibility) return;
try {
const updated = await onToggleVisibility(comment.id, !comment.visibleToClinic);
setComments((prev) => prev.map((c) => (c.id === updated.id ? updated : c)));
} catch (error: unknown) {
onError?.(formatApiErrorMessage(error, t('errorToggle')));
}
}
return (
<div className="space-y-3">
<h4 className="text-sm font-medium text-text-primary">{t('title')}</h4>
{loading ? (
<p className="text-xs text-text-muted"></p>
) : comments.length === 0 ? (
<p className="text-xs text-text-muted">{t('empty')}</p>
) : (
<ul className="space-y-2 max-h-48 overflow-y-auto">
{comments.map((comment) => (
<li
key={comment.id}
className="rounded-md border border-border bg-background p-2 text-sm"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px] text-text-muted">
<span className="font-medium text-text-secondary">
{comment.authorSide === 'LAB' ? t('labAuthor') : t('clinicAuthor')}
{comment.authorName ? ` · ${comment.authorName}` : ''}
</span>
{comment.visibleToClinic ? (
<span className="text-primary">{t('clinicCanSee')}</span>
) : (
<span>{t('hiddenFromClinic')}</span>
)}
</div>
<p className="mt-1 text-text-primary whitespace-pre-wrap">{comment.body}</p>
</div>
{canToggleVisibility && comment.canToggleVisibility && onToggleVisibility ? (
<button
type="button"
onClick={() => void handleToggle(comment)}
className="shrink-0 p-1 rounded hover:bg-border text-text-muted"
title={
comment.visibleToClinic ? t('makeHidden') : t('makeVisible')
}
aria-label={
comment.visibleToClinic ? t('makeHidden') : t('makeVisible')
}
>
{comment.visibleToClinic ? (
<Eye className="h-4 w-4" />
) : (
<EyeOff className="h-4 w-4" />
)}
</button>
) : null}
</div>
</li>
))}
</ul>
)}
{canPost ? (
<div className="space-y-2 border-t border-border pt-2">
<textarea
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder={t('placeholder')}
rows={2}
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm resize-none"
/>
{canToggleVisibility ? (
<label className="flex items-center gap-2 text-xs text-text-muted cursor-pointer">
<input
type="checkbox"
checked={visibleToClinic}
onChange={(e) => setVisibleToClinic(e.target.checked)}
/>
{t('visibleToClinicToggle')}
</label>
) : null}
<Button
type="button"
size="sm"
disabled={posting || !body.trim()}
onClick={() => void handlePost()}
>
{t('post')}
</Button>
</div>
) : null}
</div>
);
}

View File

@@ -12,6 +12,11 @@ import { Badge, type BadgeVariant } from '@/components/ui/shared/Badge';
import { Button } from '@/components/ui/shared/Button';
import { SearchBar } from '@/components/ui/shared/SearchBar';
import { ToastStack } from '@/components/ui/shared/Toast';
import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel';
import {
formatToothList,
prosthesisTypeBadgeStyle,
} from '@/components/ui/treatment/prosthesisTypeDisplay';
import type { CounterpartItemDto } from '@/lib/api/organization';
import type { LabCaseDetail, LabCaseListItem, LabTaskStatus } from '@/types/cases';
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
@@ -109,7 +114,6 @@ export function ConnectionCaseHistoryContent({
const statusOptions: { value: LabTaskStatus; label: string }[] = useMemo(
() => [
{ value: 'PENDING', label: tCases('statusPending') },
{ value: 'IN_PROGRESS', label: tCases('statusInProgress') },
{ value: 'COMPLETED', label: tCases('statusCompleted') },
],
@@ -363,17 +367,24 @@ export function ConnectionCaseHistoryContent({
{selectedCase.tasksByTooth.length === 0 ? (
<p className="text-sm text-text-muted">{tCases('noTasks')}</p>
) : (
selectedCase.tasksByTooth.map((group) => (
selectedCase.tasksByTooth.map((group, groupIndex) => (
<div
key={`${group.tooth}-${group.treatmentType}`}
key={`${group.treatmentDetailId}-${group.prosthesisTypeCode}`}
className="rounded-md border border-border p-3 space-y-2"
>
<div className="text-sm font-medium text-text-primary">
{tCases('toothGroupTitle', {
tooth: group.tooth,
prosthesis: group.prosthesisTypeLabel,
type: treatmentLabel(group.treatmentType),
})}
<div className="flex flex-wrap items-center gap-2">
<span
className="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium border"
style={prosthesisTypeBadgeStyle(group.prosthesisTypeCode, groupIndex)}
>
{group.prosthesisTypeLabel}
</span>
<span className="text-sm font-medium text-text-primary">
{tCases('toothGroupTitle', {
teeth: formatToothList(group.teeth),
prosthesis: group.prosthesisTypeLabel,
})}
</span>
</div>
<ul className="space-y-2">
{group.tasks.map((task) => (
@@ -388,6 +399,11 @@ export function ConnectionCaseHistoryContent({
{statusOptions.find((opt) => opt.value === task.status)?.label ??
task.status}
</Badge>
{task.lastStatusChangedBy ? (
<span className="text-[11px] text-text-muted">
{tCases('lastUpdatedBy', { name: task.lastStatusChangedBy.name })}
</span>
) : null}
</li>
))}
</ul>
@@ -395,6 +411,30 @@ export function ConnectionCaseHistoryContent({
))
)}
</div>
{isClinic && selectedCaseId ? (
<LabCaseCommentsPanel
caseId={selectedCaseId}
canPost
canToggleVisibility={false}
loadComments={async () => {
const r = await organizationApi.listConnectionCaseComments(
connection.id,
selectedCaseId,
);
return r.data;
}}
onPost={async (body) => {
const r = await organizationApi.addConnectionCaseComment(
connection.id,
selectedCaseId,
body,
);
return r.data;
}}
onError={showError}
/>
) : null}
</div>
)}
</section>

View File

@@ -69,6 +69,27 @@ function labCaseDraftsToPast(
}));
}
function enrichDetailsWithLabSendState(
details: TreatmentDetailDraft[],
labCaseDrafts: LabCaseDraft[],
): TreatmentDetailDraft[] {
return details.map((detail) => {
const sentLabCase = labCaseDrafts.find(
(lc) => lc.sentAt && lc.detailClientIds.includes(detail.clientId),
);
if (!sentLabCase) return detail;
return {
...detail,
labCaseId: sentLabCase.id ?? detail.labCaseId,
sentAt: sentLabCase.sentAt ?? detail.sentAt,
sends: sentLabCase.sends ?? detail.sends,
sendToOrganizationIds: sentLabCase.destinationOrganizationId
? [sentLabCase.destinationOrganizationId]
: detail.sendToOrganizationIds,
};
});
}
function buildWorkspaceSnapshot(
appointment: TreatmentAppointment,
details: TreatmentDetailDraft[],
@@ -76,8 +97,9 @@ function buildWorkspaceSnapshot(
title: string,
id?: string,
): PastTreatment {
const detailsForPreview = enrichDetailsWithLabSendState(details, labCaseDrafts);
return {
...detailsToPreviewTreatment(details, {
...detailsToPreviewTreatment(detailsForPreview, {
id: id ?? `preview-${appointment.id}`,
title,
patientId: appointment.patientId,
@@ -844,6 +866,22 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
const response = await treatmentsApi.sendLabCase(refreshedLabCase.id);
const sentDetailClientIds = new Set(labCase.detailClientIds);
setDetails((prev) =>
prev.map((detail) => {
if (!sentDetailClientIds.has(detail.clientId)) return detail;
return {
...detail,
labCaseId: response.data.id,
sentAt: response.data.sentAt,
sends: response.data.sends,
sendToOrganizationIds: response.data.destinationOrganizationId
? [response.data.destinationOrganizationId]
: detail.sendToOrganizationIds,
};
}),
);
setLabCaseDrafts((prev) =>
prev.map((lc) =>
lc.clientId === labCase.clientId

View File

@@ -0,0 +1,72 @@
import type { CSSProperties } from 'react';
/**
* Prosthesis-type colors for lab-facing surfaces (Tasks list, Cases detail group
* headers / badges). Grouped by material family, loosely inspired by exocad's
* material color conventions:
* - Zirconia family → pale green/cream
* - PFM / full metal → steel gray
* - Glass-ceramic / IPS (press & CAD) → warm amber
* - Resin / PMMA / PEEK / temporary → mint/teal
* - Abutments / screw-retained → slate blue
* - Smile design / mockup → lavender/pink
*
* Clinic-facing dispatch flows intentionally do NOT use these colors.
*/
const PROSTHESIS_TYPE_COLORS: Record<string, string> = {
// Zirconia family
monolithic_zirconia: '#d9f2e6',
pfz_crown: '#c7ede0',
veneer_zirconia: '#b8e6d5',
zirconia_abutment: '#a7dcc8',
zirconia_overlay: '#cdeede',
// PFM / metal
pfm_crown: '#cbd5e1',
full_metal_crown: '#b8c2cf',
// Glass-ceramic / IPS
glass_ceramic_crown: '#fde3a7',
veneer_ips_press: '#fcd88f',
veneer_ips_cad: '#f9cf9c',
ips_overlay: '#fbe0b0',
// Resin / PMMA / PEEK / temporary
temporary_resin_crown: '#bfeaf0',
pmma: '#a9e2ea',
peek_crown: '#b7e4dd',
soft_structure: '#d4eef0',
// Abutments / screw-retained
customized_abutment: '#aec6e8',
prefabricated_abutment: '#9db8e0',
ti_base_abutment: '#c0d0ec',
multi_unit_abutment: '#b4c4e6',
screw_retained: '#a8bce2',
// Design / mockup
smile_design: '#e9d5ff',
mockup: '#f5d0fe',
};
const FALLBACK_COLORS = ['#ddd6fe', '#fed7aa', '#fecaca', '#bae6fd', '#d9f99d', '#fbcfe8'];
/** Dark ink that stays readable on every pastel in the palette. */
const BADGE_INK = '#14253d';
export function prosthesisTypeColor(code: string, index = 0): string {
return PROSTHESIS_TYPE_COLORS[code] ?? FALLBACK_COLORS[index % FALLBACK_COLORS.length];
}
/** Filled swatch (small indicator dots). */
export function prosthesisTypeSwatchStyle(code: string, index = 0): CSSProperties {
return { backgroundColor: prosthesisTypeColor(code, index), borderColor: 'rgba(0, 0, 0, 0.18)' };
}
/** Pastel pill / banner fill with readable dark text (group headers, badges). */
export function prosthesisTypeBadgeStyle(code: string, index = 0): CSSProperties {
return {
backgroundColor: prosthesisTypeColor(code, index),
borderColor: 'rgba(0, 0, 0, 0.16)',
color: BADGE_INK,
};
}
export function formatToothList(teeth: string[]): string {
return teeth.join(', ');
}

View File

@@ -1,6 +1,5 @@
import { apiClient } from './client';
import type {
AssignableMember,
CasesFilterOptions,
LabCaseDetail,
LabCaseTask,
@@ -21,22 +20,17 @@ export const casesApi = {
return response.data;
},
listAssignableMembers: async (): Promise<{ success: boolean; data: AssignableMember[] }> => {
const response = await apiClient.get('/cases/assignable-members');
return response.data;
},
listFilterOptions: async (): Promise<{ success: boolean; data: CasesFilterOptions }> => {
const response = await apiClient.get('/cases/filter-options');
return response.data;
},
updateTask: async (
setTaskImportant: async (
caseId: string,
taskId: string,
payload: { assigneeUserId?: string | null; priority?: number },
isImportant: boolean,
): Promise<{ success: boolean; data: LabCaseTask }> => {
const response = await apiClient.patch(`/cases/${caseId}/tasks/${taskId}`, payload);
const response = await apiClient.patch(`/cases/${caseId}/tasks/${taskId}`, { isImportant });
return response.data;
},
};

View File

@@ -1,5 +1,6 @@
import { apiClient } from './client';
import type {
LabCaseComment,
LabCaseDetail,
ListLabCasesParams,
PaginatedLabCases,
@@ -161,4 +162,26 @@ export const organizationApi = {
);
return response.data;
},
listConnectionCaseComments: async (
connectionId: string,
caseId: string,
): Promise<{ success: boolean; data: LabCaseComment[] }> => {
const response = await apiClient.get(
`/organizations/connections/${connectionId}/cases/${caseId}/comments`,
);
return response.data;
},
addConnectionCaseComment: async (
connectionId: string,
caseId: string,
body: string,
): Promise<{ success: boolean; data: LabCaseComment }> => {
const response = await apiClient.post(
`/organizations/connections/${connectionId}/cases/${caseId}/comments`,
{ body },
);
return response.data;
},
};

View File

@@ -1,11 +1,16 @@
import { apiClient } from './client';
import type { LabTaskListItem, LabTaskStatus, PaginatedLabTasks } from '@/types/cases';
import type {
LabCaseComment,
LabTaskListItem,
LabTaskStatus,
ListLabTasksParams,
PaginatedLabTasks,
} from '@/types/cases';
export const tasksApi = {
list: async (params: { page?: number; limit?: number } = {}): Promise<{
success: boolean;
data: PaginatedLabTasks;
}> => {
list: async (
params: ListLabTasksParams = {},
): Promise<{ success: boolean; data: PaginatedLabTasks }> => {
const response = await apiClient.get('/tasks', { params });
return response.data;
},
@@ -17,4 +22,29 @@ export const tasksApi = {
const response = await apiClient.patch(`/tasks/${taskId}`, { status });
return response.data;
},
listComments: async (
caseId: string,
): Promise<{ success: boolean; data: LabCaseComment[] }> => {
const response = await apiClient.get(`/case-comments/${caseId}`);
return response.data;
},
addComment: async (
caseId: string,
payload: { body: string; visibleToClinic?: boolean },
): Promise<{ success: boolean; data: LabCaseComment }> => {
const response = await apiClient.post(`/case-comments/${caseId}`, payload);
return response.data;
},
setCommentVisibility: async (
commentId: string,
visibleToClinic: boolean,
): Promise<{ success: boolean; data: LabCaseComment }> => {
const response = await apiClient.patch(`/case-comments/item/${commentId}/visibility`, {
visibleToClinic,
});
return response.data;
},
};

View File

@@ -1,4 +1,4 @@
export type LabTaskStatus = 'PENDING' | 'IN_PROGRESS' | 'COMPLETED';
export type LabTaskStatus = 'IN_PROGRESS' | 'COMPLETED';
export interface LabCaseListItem {
id: string;
@@ -14,9 +14,23 @@ export interface LabCaseListItem {
taskProgress: { completed: number; total: number };
}
export interface LabTaskUser {
id: string;
name: string;
}
export interface LabTaskTimelineEvent {
id: string;
fromStatus: LabTaskStatus | null;
toStatus: LabTaskStatus;
changedAt: string;
changedBy: LabTaskUser | null;
}
export interface LabCaseTask {
id: string;
tooth: string;
treatmentDetailId: string;
teeth: string[];
treatmentType: string;
prosthesisTypeCode: string;
prosthesisTypeLabel: string;
@@ -24,21 +38,33 @@ export interface LabCaseTask {
stepOrder: number;
stepLabel: string;
status: LabTaskStatus;
priority: number;
assignedAt: string | null;
isImportant: boolean;
createdAt: string;
assigneeUserId: string | null;
assignee: { id: string; name: string; email: string } | null;
lastStatusChangedAt: string | null;
lastStatusChangedBy: LabTaskUser | null;
timeline: LabTaskTimelineEvent[];
}
export interface LabCaseTasksByTooth {
tooth: string;
export interface LabCaseTaskGroup {
treatmentDetailId: string;
teeth: string[];
treatmentType: string;
prosthesisTypeCode: string;
prosthesisTypeLabel: string;
tasks: LabCaseTask[];
}
export interface LabCaseComment {
id: string;
body: string;
authorSide: 'LAB' | 'CLINIC';
authorName: string | null;
authorOrganizationName: string | null;
visibleToClinic: boolean;
createdAt: string;
canToggleVisibility: boolean;
}
export interface LabCaseDetail {
id: string;
sentAt: string | null;
@@ -64,17 +90,10 @@ export interface LabCaseDetail {
sentAt: string;
}>;
tasks: LabCaseTask[];
tasksByTooth: LabCaseTasksByTooth[];
tasksByTooth: LabCaseTaskGroup[];
taskProgress: { completed: number; total: number };
}
export interface AssignableMember {
userId: string;
name: string;
email: string;
isOwner: boolean;
}
export interface ListLabCasesParams {
q?: string;
page?: number;
@@ -100,21 +119,38 @@ export interface PaginatedLabCases {
};
}
export type TaskSortField = 'date' | 'status' | 'clinic' | 'patient' | 'important';
export interface ListLabTasksParams {
q?: string;
clinicOrganizationId?: string;
status?: LabTaskStatus;
completed?: boolean;
important?: boolean;
sentFrom?: string;
sentTo?: string;
sortBy?: TaskSortField;
sortDir?: 'asc' | 'desc';
page?: number;
limit?: number;
}
export interface LabTaskListItem {
id: string;
labCaseId: string;
tooth: string;
treatmentDetailId: string;
teeth: string[];
treatmentType: string;
prosthesisTypeCode: string;
prosthesisTypeLabel: string;
workflowStepCode: string;
stepOrder: number;
stepLabel: string;
status: LabTaskStatus;
priority: number;
assignedAt: string | null;
isImportant: boolean;
lastStatusChangedAt: string | null;
lastStatusChangedBy: LabTaskUser | null;
createdAt: string;
assigneeUserId: string | null;
assignee: { id: string; name: string; email: string } | null;
clinic: { id: string; name: string };
patient: { id: string; firstName: string; lastName: string };
}