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

@@ -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(', ');
}