improvement: treatment preview wizard optimized. comments component updated and unified accross the app.

This commit is contained in:
2026-07-17 12:30:43 +03:30
parent 58676f702e
commit 4b2349228a
19 changed files with 163 additions and 149 deletions

View File

@@ -669,8 +669,8 @@
"detailsSaveHint": "Lab dispatch is configured separately below.",
"addCase": "Add case",
"caseLabel": "Case {n}",
"comments": "Comments",
"commentsPlaceholder": "Write clinical notes for this case…",
"comments": "Notes",
"commentsPlaceholder": "Write clinical notes for this detail…",
"treatmentType": "Treatment type",
"treatmentTypePlaceholder": "Select treatment type…",
"treatmentTypeNotSelected": "Type not selected",

View File

@@ -670,8 +670,8 @@
"detailsSaveHint": "ارسال لاب در بخش جداگانه زیر پیکربندی می‌شود.",
"addCase": "افزودن پرونده",
"caseLabel": "پرونده {n}",
"comments": "نظرات",
"commentsPlaceholder": "یادداشت‌های بالینی این پرونده را بنویسید...",
"comments": "یادداشت‌ها",
"commentsPlaceholder": "یادداشت‌های بالینی این جزئیات را بنویسید",
"treatmentType": "نوع درمان",
"treatmentTypePlaceholder": "نوع درمان را انتخاب کنید…",
"treatmentTypeNotSelected": "نوع انتخاب نشده",

View File

@@ -669,8 +669,8 @@
"detailsSaveHint": "Lab-dispatch wordt hieronder apart geconfigureerd.",
"addCase": "Case toevoegen",
"caseLabel": "Case {n}",
"comments": "Opmerkingen",
"commentsPlaceholder": "Schrijf klinische notities voor deze case...",
"comments": "Notities",
"commentsPlaceholder": "Schrijf klinische notities voor dit detail…",
"treatmentType": "Behandeltype",
"treatmentTypePlaceholder": "Selecteer behandeltype…",
"treatmentTypeNotSelected": "Type niet geselecteerd",

View File

@@ -182,6 +182,7 @@ export function CaseTasksFocusView({ token }: CaseTasksFocusViewProps) {
<section className="surface-card p-3 sm:p-4 min-w-0 overflow-x-hidden">
<LabCaseCommentsPanel
caseId={session.labCaseId}
viewerSide={session.accessMode === 'lab' ? 'LAB' : 'CLINIC'}
canPost={canPostComments}
canToggleVisibility={canToggleCommentVisibility}
loadComments={async () => {

View File

@@ -535,6 +535,7 @@ export function CasesPage() {
<section id="case-comments" className="scroll-mt-4 border-t border-border pt-4">
<LabCaseCommentsPanel
caseId={selectedCaseId}
viewerSide="LAB"
canPost={canEditComments}
canToggleVisibility={canEditComments}
loadComments={async () => {

View File

@@ -1,14 +1,17 @@
'use client';
import { useCallback, useEffect, useState, type KeyboardEvent } from 'react';
import { useCallback, useEffect, useMemo, useState, type KeyboardEvent } from 'react';
import { useTranslations } from 'next-intl';
import { Eye, EyeOff, Send } from 'lucide-react';
import { getUserFacingError } from '@/components/shared/formatApiError';
import { useAsyncActionById } from '@/lib/hooks/useAsyncAction';
import type { LabCaseComment } from '@/types/cases';
export type LabCaseCommentViewerSide = 'LAB' | 'CLINIC';
interface LabCaseCommentsPanelProps {
caseId: string;
viewerSide: LabCaseCommentViewerSide;
canPost: boolean;
canToggleVisibility: boolean;
loadComments: () => Promise<LabCaseComment[]>;
@@ -24,8 +27,15 @@ interface LabCaseCommentsPanelProps {
onComposerValueChange?: (value: string) => void;
}
function sortNewestFirst(items: LabCaseComment[]): LabCaseComment[] {
return [...items].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
);
}
export function LabCaseCommentsPanel({
caseId,
viewerSide,
canPost,
canToggleVisibility,
loadComments,
@@ -45,17 +55,19 @@ export function LabCaseCommentsPanel({
const [visibleToClinic, setVisibleToClinic] = useState(false);
const toggleBusy = useAsyncActionById();
const orderedComments = useMemo(() => sortNewestFirst(comments), [comments]);
const refresh = useCallback(async () => {
setLoading(true);
try {
const items = await loadComments();
setComments(items);
setComments(sortNewestFirst(items));
} catch (error: unknown) {
onError?.(getUserFacingError(error, tErrors, t('errorLoad')));
} finally {
setLoading(false);
}
}, [loadComments, onError, t]);
}, [loadComments, onError, t, tErrors]);
useEffect(() => {
void refresh();
@@ -67,7 +79,7 @@ export function LabCaseCommentsPanel({
setPosting(true);
try {
const created = await onPost(trimmed, visibleToClinic);
setComments((prev) => [...prev, created]);
setComments((prev) => sortNewestFirst([created, ...prev]));
setBody('');
setVisibleToClinic(false);
} catch (error: unknown) {
@@ -96,8 +108,11 @@ export function LabCaseCommentsPanel({
});
}
const composerInputClass =
'min-w-0 flex-1 h-9 rounded-md border border-border bg-surface px-3 py-1.5 text-sm leading-tight resize-none';
return (
<div className="space-y-3">
<div className="space-y-3 min-w-0">
<h4 className="text-sm font-medium text-text-primary">{t('title')}</h4>
{loading ? (
@@ -105,15 +120,22 @@ export function LabCaseCommentsPanel({
) : 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">
<div className="max-h-48 overflow-y-auto overflow-x-hidden rounded-md border border-border bg-background p-2 space-y-2">
{orderedComments.map((comment) => {
const mine = comment.authorSide === viewerSide;
return (
<div
key={comment.id}
className={`flex min-w-0 ${mine ? 'justify-start' : 'justify-end'}`}
>
<div
className={`max-w-[85%] min-w-0 text-sm break-words ${mine ? 'text-start' : 'text-end'}`}
>
<div
className={`flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px] text-text-muted ${
mine ? 'justify-start' : 'justify-end'
}`}
>
<span className="font-medium text-text-secondary">
{comment.authorSide === 'LAB' ? t('labAuthor') : t('clinicAuthor')}
{comment.authorName ? ` · ${comment.authorName}` : ''}
@@ -125,32 +147,32 @@ export function LabCaseCommentsPanel({
<span>{t('hiddenFromClinic')}</span>
)
) : null}
{canToggleVisibility && comment.canToggleVisibility && onToggleVisibility ? (
<button
type="button"
onClick={() => handleToggle(comment)}
disabled={toggleBusy.pendingId !== null}
className={`shrink-0 p-0.5 rounded hover:bg-border text-text-muted disabled:opacity-40 disabled:cursor-not-allowed ${
toggleBusy.pendingId === comment.id ? 'animate-pulse' : ''
}`}
title={comment.visibleToClinic ? t('makeHidden') : t('makeVisible')}
aria-label={comment.visibleToClinic ? t('makeHidden') : t('makeVisible')}
aria-busy={toggleBusy.pendingId === comment.id || undefined}
>
{comment.visibleToClinic ? (
<Eye className="h-3.5 w-3.5" />
) : (
<EyeOff className="h-3.5 w-3.5" />
)}
</button>
) : null}
</div>
<p className="mt-1 text-text-primary whitespace-pre-wrap">{comment.body}</p>
<p className="mt-0.5 text-text-primary whitespace-pre-wrap">{comment.body}</p>
</div>
{canToggleVisibility && comment.canToggleVisibility && onToggleVisibility ? (
<button
type="button"
onClick={() => handleToggle(comment)}
disabled={toggleBusy.pendingId !== null}
className={`shrink-0 p-1 rounded hover:bg-border text-text-muted disabled:opacity-40 disabled:cursor-not-allowed ${
toggleBusy.pendingId === comment.id ? 'animate-pulse' : ''
}`}
title={comment.visibleToClinic ? t('makeHidden') : t('makeVisible')}
aria-label={comment.visibleToClinic ? t('makeHidden') : t('makeVisible')}
aria-busy={toggleBusy.pendingId === comment.id || undefined}
>
{comment.visibleToClinic ? (
<Eye className="h-4 w-4" />
) : (
<EyeOff className="h-4 w-4" />
)}
</button>
) : null}
</div>
</li>
))}
</ul>
);
})}
</div>
)}
{canPost && deferSubmit ? (
@@ -159,46 +181,48 @@ export function LabCaseCommentsPanel({
value={composerValue ?? ''}
onChange={(e) => onComposerValueChange?.(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"
rows={1}
className="w-full h-9 rounded-md border border-border bg-surface px-3 py-1.5 text-sm leading-tight resize-none"
/>
</div>
) : canPost ? (
<div className="flex items-end gap-2 border-t border-border pt-2">
<div className="flex items-center gap-2 border-t border-border pt-2 min-w-0">
<textarea
value={body}
onChange={(e) => setBody(e.target.value)}
onKeyDown={handleComposerKeyDown}
placeholder={t('placeholder')}
rows={2}
className="min-w-0 flex-1 rounded-md border border-border bg-surface px-3 py-2 text-sm resize-none"
rows={1}
className={composerInputClass}
/>
<div className="flex items-center gap-1 pb-1">
<div className="flex items-center gap-1 shrink-0">
{canToggleVisibility ? (
<button
type="button"
onClick={() => setVisibleToClinic((v) => !v)}
className={`shrink-0 p-2 rounded-md border transition-colors ${
visibleToClinic
? 'border-primary/40 bg-primary/10 text-primary'
: 'border-border text-text-muted hover:text-text-primary'
className={`shrink-0 h-9 w-9 inline-flex items-center justify-center rounded-md bg-primary text-white transition-colors hover:opacity-90 ${
visibleToClinic ? '' : 'opacity-50'
}`}
title={visibleToClinic ? t('composerVisible') : t('composerHidden')}
aria-label={visibleToClinic ? t('composerVisible') : t('composerHidden')}
aria-pressed={visibleToClinic}
>
{visibleToClinic ? <Eye className="h-4 w-4" /> : <EyeOff className="h-4 w-4" />}
{visibleToClinic ? (
<Eye className="h-4 w-4 text-white stroke-current" />
) : (
<EyeOff className="h-4 w-4 text-white stroke-current" />
)}
</button>
) : null}
<button
type="button"
onClick={() => handlePost()}
disabled={posting || !body.trim()}
className="shrink-0 p-2 rounded-md bg-primary text-white transition-colors hover:opacity-90 disabled:opacity-40 disabled:cursor-not-allowed"
className="shrink-0 h-9 w-9 inline-flex items-center justify-center rounded-md bg-primary text-white transition-colors hover:opacity-90 disabled:opacity-40 disabled:cursor-not-allowed"
title={t('send')}
aria-label={t('send')}
>
<Send className="h-4 w-4" />
<Send className="h-4 w-4 text-white stroke-current fill-none rtl:-scale-x-100" />
</button>
</div>
</div>

View File

@@ -217,6 +217,7 @@ export function TaskRow({
<div className="px-3 pb-3 border-t border-border/50">
<LabCaseCommentsPanel
caseId={task.labCaseId}
viewerSide="LAB"
canPost
canToggleVisibility
loadComments={async () => {

View File

@@ -538,6 +538,7 @@ export function TasksPage() {
<div className="border-b border-border/50 px-3 pb-3">
<LabCaseCommentsPanel
caseId={caseGroup.labCaseId}
viewerSide="LAB"
canPost
canToggleVisibility
loadComments={async () => {

View File

@@ -39,6 +39,7 @@ export function DetailLabCaseCommentsSection({
<div className="border-t border-border/60 pt-4">
<LabCaseCommentsPanel
caseId={labCaseId}
viewerSide="CLINIC"
canPost={canPost}
canToggleVisibility={false}
deferSubmit={deferSubmit}

View File

@@ -341,7 +341,7 @@ export function FdiToothChart({
</div>
<div className="flex flex-col items-start gap-1.5 sm:items-end shrink-0">
{headerControl}
<p className="text-[11px] text-text-secondary tabular-nums sm:text-right">
<p className="text-[11px] text-text-secondary tabular-nums sm:text-end">
{t('selectedLabel')}{' '}
{selected.size === 0 ? t('selectedEmpty') : [...selected].sort().join(', ')}
</p>

View File

@@ -45,19 +45,10 @@ interface LabCasesDispatchPanelProps {
canInviteLab?: boolean;
onInviteLab?: () => void;
sendBusyId: string | null;
onAddLabCase: () => void | Promise<void>;
onSendLabCase: (labCase: LabCaseDraft, comment?: string) => void | Promise<void>;
onCommentError?: (message: string) => void;
}
function sentDetailClientIds(labCases: LabCaseDraft[]): Set<string> {
const ids = new Set<string>();
for (const lc of labCases) {
if (lc.sentAt && lc.detailClientId) ids.add(lc.detailClientId);
}
return ids;
}
type ProsthesisGroupRow = {
groupId: string;
kind: 'connected' | 'single';
@@ -129,7 +120,6 @@ export function LabCasesDispatchPanel({
canInviteLab = false,
onInviteLab,
sendBusyId,
onAddLabCase,
onSendLabCase,
onCommentError,
}: LabCasesDispatchPanelProps) {
@@ -157,8 +147,6 @@ export function LabCasesDispatchPanel({
(activeLabCaseId ? labCases.find((lc) => lc.clientId === activeLabCaseId) : null);
const detailAlreadyInShipment = Boolean(labCaseForActiveDetail);
const canAddLabShipment =
!detailAlreadyInShipment && !sentDetailClientIds(labCases).has(activeDetailId);
const sent = Boolean(activeLabCase?.sentAt);
const activeDetailNumber = details.findIndex((d) => d.clientId === activeDetailId) + 1;
@@ -289,23 +277,14 @@ export function LabCasesDispatchPanel({
}
}
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);
const dueDateInputId = `lab-case-due-date-${activeLabCase.clientId}`;
return (
<div className="shrink-0 sm:text-end">
<div className="flex items-center gap-2 sm:justify-end">
<div className="w-full min-w-0 sm:w-auto sm:shrink-0 sm:text-end">
<div className="flex flex-wrap items-center gap-2 sm:justify-end">
<label
htmlFor={dueDateInputId}
className="text-xs font-medium text-text-secondary shrink-0"
@@ -323,7 +302,7 @@ export function LabCasesDispatchPanel({
onBlur={(committed) => {
if (sent) void handleSentDueDateBlur(committed);
}}
className={`${FORM_SELECT_CLASS} w-full max-w-[11rem] rounded-md py-1.5 text-sm`}
className={`${FORM_SELECT_CLASS} w-full min-w-0 max-w-full sm:max-w-[11rem] rounded-md py-1.5 text-sm`}
/>
</div>
{sent && caseFullyComplete && activeLabCase.dueDate ? (
@@ -335,7 +314,6 @@ export function LabCasesDispatchPanel({
function renderIncludedDetailSummary() {
if (!activeDetail) return null;
const typeLabel = treatmentTypeLabelFromCatalog(activeDetail.treatmentType, treatmentCatalog);
if (activeDetail.treatmentType !== 'prosthesis' || !activeLabCase) {
return (
@@ -375,9 +353,6 @@ export function LabCasesDispatchPanel({
return (
<div className="text-sm text-text-primary rounded-[var(--radius-sm)] border border-border/50 bg-background-secondary/50 px-3 py-2 space-y-1">
<p className="text-text-primary">
{t('detailLabel', { n: activeDetailNumber })} · {typeLabel}
</p>
{rows.map((g) => (
<p
key={g.groupId}
@@ -401,31 +376,19 @@ export function LabCasesDispatchPanel({
const activeDetailAttachments = activeDetail.attachmentMetas ?? [];
return (
<div className="surface-card p-4 space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="surface-card p-3 sm:p-4 space-y-4 min-w-0 overflow-x-hidden">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<h3 className="text-sm font-semibold text-text-primary">{t('labDispatchTitle')}</h3>
<p className="text-xs text-text-muted mt-0.5">
{t('labDispatchSubtitle')} {t('labDispatchSendHint')}
</p>
</div>
{canAddLabShipment && (
<Button
type="button"
variant="primary"
disabled={!canEdit || disabled}
onClick={onAddLabCase}
>
{t('addLabShipment')}
</Button>
)}
{detailAlreadyInShipment ? renderDueDateField() : null}
</div>
{!detailAlreadyInShipment ? (
<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-3 sm:p-4 bg-background-secondary/30">
{renderShipmentCardHeader()}
{activeLabCase ? (
<div className="space-y-4 border border-border/60 rounded-[var(--radius-md)] p-3 sm:p-4 bg-background-secondary/30 min-w-0">
{sent ? (
<>
{renderIncludedDetailSummary()}
@@ -589,39 +552,38 @@ export function LabCasesDispatchPanel({
return (
<label
key={row.groupId}
className="block text-xs text-text-muted space-y-1 rounded-[var(--radius-sm)] border border-border/50 bg-background-secondary/40 px-3 py-2"
className="block text-xs text-text-muted rounded-[var(--radius-sm)] border border-border/50 bg-background-secondary/40 px-3 py-2 min-w-0"
>
<span className="flex flex-wrap items-center gap-2 text-text-secondary">
{row.kind === 'connected' ? <ConnectedSelectionBadge /> : null}
<span>
{row.kind === 'connected'
? t('prosthesisConnectedLabel')
: t('prosthesisColTooth')}
{': '}
<span className="text-text-primary">{row.teeth.join(', ')}</span>
</span>
<span className="text-text-muted">
· {t('detailLabel', { n: row.detailNumber })}
<span className="grid grid-cols-1 gap-2 md:grid-cols-2 md:items-center md:gap-3">
<span className="flex flex-wrap items-center gap-2 text-text-secondary min-w-0 break-words">
{row.kind === 'connected' ? <ConnectedSelectionBadge /> : null}
<span className="min-w-0">
{row.kind === 'connected'
? t('prosthesisConnectedLabel')
: t('prosthesisColTooth')}
{': '}
<span className="text-text-primary">{row.teeth.join(', ')}</span>
</span>
</span>
<select
value={current}
disabled={disabled}
onChange={(e) => setGroupProsthesis(row, e.target.value)}
className={`${FORM_SELECT_CLASS} w-full min-w-0`}
aria-label={
row.kind === 'connected'
? t('prosthesisConnectedLabel')
: t('prosthesisColType')
}
>
<option value="">{t('prosthesisSelectPlaceholder')}</option>
{prosthesisOptions.map((opt) => (
<option key={opt.code} value={opt.code}>
{opt.label}
</option>
))}
</select>
</span>
<select
value={current}
disabled={disabled}
onChange={(e) => setGroupProsthesis(row, e.target.value)}
className={`${FORM_SELECT_CLASS} w-full mt-1`}
aria-label={
row.kind === 'connected'
? t('prosthesisConnectedLabel')
: t('prosthesisColType')
}
>
<option value="">{t('prosthesisSelectPlaceholder')}</option>
{prosthesisOptions.map((opt) => (
<option key={opt.code} value={opt.code}>
{opt.label}
</option>
))}
</select>
</label>
);
})}

View File

@@ -221,9 +221,9 @@ export function TreatmentDetailsEditor({
);
}}
placeholder={t('commentsPlaceholder')}
rows={5}
rows={2}
disabled={readOnly}
className="mt-1.5 w-full rounded-[var(--radius-md)] border border-border bg-background-secondary/90 text-text-primary text-sm px-3 py-2 placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/35 resize-y min-h-[120px]"
className="mt-1.5 w-full rounded-[var(--radius-md)] border border-border bg-background-secondary/90 text-text-primary text-sm px-3 py-2 placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/35 resize-y min-h-[60px]"
/>
</label>

View File

@@ -1547,6 +1547,27 @@ export function TreatmentWorkspace({
t,
]);
// Auto-open shipment draft when entering Lab (no manual "Add lab shipment" click).
useEffect(() => {
if (entryStep !== 'lab') return;
if (!canEditTreatmentForDay || !selectedAppointment) return;
const activeDetail = details.find((d) => d.clientId === activeDetailId);
if (!activeDetail || !isDetailReadyForLabDispatch(activeDetail, labDependentCodes)) return;
if (isLabDependentDetailMissingTeeth(activeDetail, labDependentCodes)) return;
const hasDraft = labCaseDrafts.some((lc) => lc.detailClientId === activeDetailId);
if (hasDraft) return;
void handleAddLabCase();
}, [
activeDetailId,
canEditTreatmentForDay,
details,
entryStep,
handleAddLabCase,
labCaseDrafts,
labDependentCodes,
selectedAppointment,
]);
const handleSendLabCase = useCallback(
async (labCase: LabCaseDraft, comment?: string) => {
if (!canEditTreatmentForDay || !selectedAppointment) return;
@@ -2058,7 +2079,6 @@ export function TreatmentWorkspace({
});
}}
sendBusyId={sendBusyId}
onAddLabCase={() => handleAddLabCase()}
onSendLabCase={(lc, comment) => handleSendLabCase(lc, comment)}
onCommentError={showError}
canInviteLab={canAccessOrganizations}