2026-07-07 15:31:09 +03:30
|
|
|
'use client';
|
|
|
|
|
|
2026-07-08 02:53:47 +03:30
|
|
|
import { useCallback, useEffect, useState, type KeyboardEvent } from 'react';
|
2026-07-07 15:31:09 +03:30
|
|
|
import { useTranslations } from 'next-intl';
|
2026-07-08 02:53:47 +03:30
|
|
|
import { Eye, EyeOff, Send } from 'lucide-react';
|
2026-07-12 18:27:38 +03:30
|
|
|
import { getUserFacingError } from '@/components/shared/formatApiError';
|
2026-07-07 15:31:09 +03:30
|
|
|
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;
|
2026-07-08 02:53:47 +03:30
|
|
|
/**
|
|
|
|
|
* 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;
|
2026-07-07 15:31:09 +03:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function LabCaseCommentsPanel({
|
|
|
|
|
caseId,
|
|
|
|
|
canPost,
|
|
|
|
|
canToggleVisibility,
|
|
|
|
|
loadComments,
|
|
|
|
|
onPost,
|
|
|
|
|
onToggleVisibility,
|
|
|
|
|
onError,
|
2026-07-08 02:53:47 +03:30
|
|
|
deferSubmit = false,
|
|
|
|
|
composerValue,
|
|
|
|
|
onComposerValueChange,
|
2026-07-07 15:31:09 +03:30
|
|
|
}: LabCaseCommentsPanelProps) {
|
|
|
|
|
const t = useTranslations('caseComments');
|
2026-07-12 18:27:38 +03:30
|
|
|
const tErrors = useTranslations('errors');
|
2026-07-07 15:31:09 +03:30
|
|
|
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) {
|
2026-07-12 18:27:38 +03:30
|
|
|
onError?.(getUserFacingError(error, tErrors, t('errorLoad')));
|
2026-07-07 15:31:09 +03:30
|
|
|
} finally {
|
|
|
|
|
setLoading(false);
|
|
|
|
|
}
|
|
|
|
|
}, [loadComments, onError, t]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
void refresh();
|
|
|
|
|
}, [caseId, refresh]);
|
|
|
|
|
|
|
|
|
|
async function handlePost() {
|
|
|
|
|
const trimmed = body.trim();
|
2026-07-08 02:53:47 +03:30
|
|
|
if (!trimmed || !canPost || posting) return;
|
2026-07-07 15:31:09 +03:30
|
|
|
setPosting(true);
|
|
|
|
|
try {
|
|
|
|
|
const created = await onPost(trimmed, visibleToClinic);
|
|
|
|
|
setComments((prev) => [...prev, created]);
|
|
|
|
|
setBody('');
|
|
|
|
|
setVisibleToClinic(false);
|
|
|
|
|
} catch (error: unknown) {
|
2026-07-12 18:27:38 +03:30
|
|
|
onError?.(getUserFacingError(error, tErrors, t('errorPost')));
|
2026-07-07 15:31:09 +03:30
|
|
|
} finally {
|
|
|
|
|
setPosting(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-08 02:53:47 +03:30
|
|
|
function handleComposerKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
|
|
|
|
|
if (event.key === 'Enter' && !event.shiftKey) {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
void handlePost();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-07 15:31:09 +03:30
|
|
|
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) {
|
2026-07-12 18:27:38 +03:30
|
|
|
onError?.(getUserFacingError(error, tErrors, t('errorToggle')));
|
2026-07-07 15:31:09 +03:30
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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>
|
2026-07-07 17:14:31 +03:30
|
|
|
{comment.showVisibilityStatus !== false ? (
|
|
|
|
|
comment.visibleToClinic ? (
|
|
|
|
|
<span className="text-primary">{t('clinicCanSee')}</span>
|
|
|
|
|
) : (
|
|
|
|
|
<span>{t('hiddenFromClinic')}</span>
|
|
|
|
|
)
|
|
|
|
|
) : null}
|
2026-07-07 15:31:09 +03:30
|
|
|
</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"
|
2026-07-08 02:53:47 +03:30
|
|
|
title={comment.visibleToClinic ? t('makeHidden') : t('makeVisible')}
|
|
|
|
|
aria-label={comment.visibleToClinic ? t('makeHidden') : t('makeVisible')}
|
2026-07-07 15:31:09 +03:30
|
|
|
>
|
|
|
|
|
{comment.visibleToClinic ? (
|
|
|
|
|
<Eye className="h-4 w-4" />
|
|
|
|
|
) : (
|
|
|
|
|
<EyeOff className="h-4 w-4" />
|
|
|
|
|
)}
|
|
|
|
|
</button>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
</li>
|
|
|
|
|
))}
|
|
|
|
|
</ul>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-07-08 02:53:47 +03:30
|
|
|
{canPost && deferSubmit ? (
|
|
|
|
|
<div className="border-t border-border pt-2">
|
2026-07-07 15:31:09 +03:30
|
|
|
<textarea
|
2026-07-08 02:53:47 +03:30
|
|
|
value={composerValue ?? ''}
|
|
|
|
|
onChange={(e) => onComposerValueChange?.(e.target.value)}
|
2026-07-07 15:31:09 +03:30
|
|
|
placeholder={t('placeholder')}
|
|
|
|
|
rows={2}
|
|
|
|
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm resize-none"
|
|
|
|
|
/>
|
2026-07-08 02:53:47 +03:30
|
|
|
</div>
|
|
|
|
|
) : canPost ? (
|
|
|
|
|
<div className="flex items-end gap-2 border-t border-border pt-2">
|
|
|
|
|
<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"
|
|
|
|
|
/>
|
|
|
|
|
<div className="flex items-center gap-1 pb-1">
|
|
|
|
|
{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'
|
|
|
|
|
}`}
|
|
|
|
|
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" />}
|
|
|
|
|
</button>
|
|
|
|
|
) : null}
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => void 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"
|
|
|
|
|
title={t('send')}
|
|
|
|
|
aria-label={t('send')}
|
|
|
|
|
>
|
|
|
|
|
<Send className="h-4 w-4" />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
2026-07-07 15:31:09 +03:30
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|