Files
dyolink/frontend/src/components/ui/lab/LabCaseCommentsPanel.tsx

233 lines
9.0 KiB
TypeScript

'use client';
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[]>;
onPost: (body: string, visibleToClinic?: boolean) => Promise<LabCaseComment>;
onToggleVisibility?: (commentId: string, visible: boolean) => Promise<LabCaseComment>;
onError?: (message: string) => void;
/**
* Deferred composer: the parent owns the draft value and triggers the post
* elsewhere (e.g. the "Send to lab" button). No send icon is shown.
*/
deferSubmit?: boolean;
composerValue?: string;
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,
onPost,
onToggleVisibility,
onError,
deferSubmit = false,
composerValue,
onComposerValueChange,
}: LabCaseCommentsPanelProps) {
const t = useTranslations('caseComments');
const tErrors = useTranslations('errors');
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 toggleBusy = useAsyncActionById();
const orderedComments = useMemo(() => sortNewestFirst(comments), [comments]);
const refresh = useCallback(async () => {
setLoading(true);
try {
const items = await loadComments();
setComments(sortNewestFirst(items));
} catch (error: unknown) {
onError?.(getUserFacingError(error, tErrors, t('errorLoad')));
} finally {
setLoading(false);
}
}, [loadComments, onError, t, tErrors]);
useEffect(() => {
void refresh();
}, [caseId, refresh]);
async function handlePost() {
const trimmed = body.trim();
if (!trimmed || !canPost || posting) return;
setPosting(true);
try {
const created = await onPost(trimmed, visibleToClinic);
setComments((prev) => sortNewestFirst([created, ...prev]));
setBody('');
setVisibleToClinic(false);
} catch (error: unknown) {
onError?.(getUserFacingError(error, tErrors, t('errorPost')));
} finally {
setPosting(false);
}
}
function handleComposerKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
void handlePost();
}
}
async function handleToggle(comment: LabCaseComment) {
if (!onToggleVisibility || !canToggleVisibility) return;
await toggleBusy.run(comment.id, async () => {
try {
const updated = await onToggleVisibility(comment.id, !comment.visibleToClinic);
setComments((prev) => prev.map((c) => (c.id === updated.id ? updated : c)));
} catch (error: unknown) {
onError?.(getUserFacingError(error, tErrors, t('errorToggle')));
}
});
}
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 min-w-0">
<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>
) : (
<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}` : ''}
</span>
{comment.showVisibilityStatus !== false ? (
comment.visibleToClinic ? (
<span className="text-primary">{t('clinicCanSee')}</span>
) : (
<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-0.5 text-text-primary whitespace-pre-wrap">{comment.body}</p>
</div>
</div>
);
})}
</div>
)}
{canPost && deferSubmit ? (
<div className="border-t border-border pt-2">
<textarea
value={composerValue ?? ''}
onChange={(e) => onComposerValueChange?.(e.target.value)}
placeholder={t('placeholder')}
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-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={1}
className={composerInputClass}
/>
<div className="flex items-center gap-1 shrink-0">
{canToggleVisibility ? (
<button
type="button"
onClick={() => setVisibleToClinic((v) => !v)}
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 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 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 text-white stroke-current fill-none rtl:-scale-x-100" />
</button>
</div>
</div>
) : null}
</div>
);
}