improvement: treatment preview wizard optimized. comments component updated and unified accross the app.
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user