improvement: QRcode and shared link added to cases inorder to make it possible for staff users to share a case info with other staffs or other clinics.

This commit is contained in:
2026-07-14 21:07:05 +03:30
parent 953e609fb8
commit 08df2f71ea
41 changed files with 1535 additions and 83 deletions

View File

@@ -10,6 +10,10 @@ import { CaseToothChartPanel } from '@/components/ui/lab/CaseToothChartPanel';
import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles';
import { LabCaseAttachmentPreview } from '@/components/ui/lab/LabCaseAttachmentPreview';
import { LabCaseAttachmentsDialog } from '@/components/ui/lab/LabCaseAttachmentsDialog';
import {
LabCaseShareQrDialog,
LabCaseShareQrThumb,
} from '@/components/ui/lab/LabCaseShareQrDialog';
import { labTaskStatusVariant } from '@/components/lab/labTaskStatusDisplay';
import { LabCaseDueDateBadge } from '@/components/lab/LabCaseDueDateBadge';
import {
@@ -89,6 +93,7 @@ export function CaseDetailPanel({
}: CaseDetailPanelProps) {
const t = useTranslations('cases');
const [attachmentsDialogOpen, setAttachmentsDialogOpen] = useState(false);
const [shareQrDialogOpen, setShareQrDialogOpen] = useState(false);
const [prosthesisCatalog, setProsthesisCatalog] = useState<ProsthesisCatalogEntry[]>([]);
useEffect(() => {
@@ -137,15 +142,7 @@ export function CaseDetailPanel({
</div>
</div>
<div className="flex w-full sm:w-auto shrink-0 flex-row sm:flex-col items-center sm:items-end justify-between sm:justify-start gap-2">
{showCommentsButton && onCommentsClick ? (
<Button type="button" variant="outline" size="sm" onClick={onCommentsClick}>
<MessageSquare className="h-4 w-4 me-1.5" />
{commentCount > 0
? t('commentsCount', { count: commentCount })
: t('showComments')}
</Button>
) : null}
<div className="flex w-full sm:w-auto shrink-0 flex-col items-end gap-2">
{canEditImportant ? (
<Checkbox
checked={labCase.isImportant ?? false}
@@ -155,21 +152,39 @@ export function CaseDetailPanel({
onChange={(checked) => onImportantChange?.(checked)}
/>
) : null}
{previewAttachment && labCase.attachments.length > 0 ? (
<button
type="button"
onClick={() => setAttachmentsDialogOpen(true)}
className="aspect-square w-24 sm:w-32 cursor-pointer rounded-[var(--radius-md)] border border-border/60 overflow-hidden transition-colors hover:border-primary/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
title={previewAttachment.fileName}
aria-label={t('viewAttachments')}
>
<LabCaseAttachmentPreview
caseId={labCase.id}
attachment={previewAttachment}
loadBlob={loadAttachmentBlob}
className="h-full w-full"
/>
</button>
{showCommentsButton && onCommentsClick ? (
<Button type="button" variant="outline" size="sm" onClick={onCommentsClick}>
<MessageSquare className="h-4 w-4 me-1.5" />
{commentCount > 0
? t('commentsCount', { count: commentCount })
: t('showComments')}
</Button>
) : null}
{labCase.shareUrl || (previewAttachment && labCase.attachments.length > 0) ? (
<div className="flex flex-row items-center gap-2 shrink-0">
{previewAttachment && labCase.attachments.length > 0 ? (
<button
type="button"
onClick={() => setAttachmentsDialogOpen(true)}
className="aspect-square w-24 sm:w-32 cursor-pointer rounded-[var(--radius-md)] border border-border/60 overflow-hidden transition-colors hover:border-primary/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
title={previewAttachment.fileName}
aria-label={t('viewAttachments')}
>
<LabCaseAttachmentPreview
caseId={labCase.id}
attachment={previewAttachment}
loadBlob={loadAttachmentBlob}
className="h-full w-full"
/>
</button>
) : null}
{labCase.shareUrl ? (
<LabCaseShareQrThumb
shareUrl={labCase.shareUrl}
onClick={() => setShareQrDialogOpen(true)}
/>
) : null}
</div>
) : null}
</div>
</header>
@@ -279,6 +294,14 @@ export function CaseDetailPanel({
attachments={labCase.attachments}
loadBlob={loadAttachmentBlob}
/>
{labCase.shareUrl ? (
<LabCaseShareQrDialog
open={shareQrDialogOpen}
onClose={() => setShareQrDialogOpen(false)}
shareUrl={labCase.shareUrl}
/>
) : null}
</div>
);
}

View File

@@ -0,0 +1,216 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslations } from 'next-intl';
import { TaskCaseGroupHeader } from '@/components/ui/lab/TaskCaseGroupHeader';
import { TaskProsthesisGroupHeader } from '@/components/ui/lab/TaskProsthesisGroupHeader';
import { TaskRow } from '@/components/ui/lab/TaskRow';
import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel';
import { groupTasksForDisplay } from '@/components/lab/taskListGrouping';
import { canEditLabTaskStatus } from '@/components/lab/labTaskStatusDisplay';
import { getUserFacingError } from '@/components/shared/formatApiError';
import { asApiError } from '@/types/api';
import { useAuth } from '@/lib/hooks/useAuth';
import { useToast } from '@/lib/hooks/useToast';
import { labCaseAccessApi, type LabCaseAccessSession } from '@/lib/api/lab-case-access';
import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog';
import { tasksApi } from '@/lib/api/tasks';
import { notifyTabBadgesChanged } from '@/lib/tabBadgeUtils';
import type { LabTaskListItem, LabTaskStatus } from '@/types/cases';
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
interface CaseTasksFocusViewProps {
token: string;
}
export function CaseTasksFocusView({ token }: CaseTasksFocusViewProps) {
const t = useTranslations('labCaseAccess');
const tTasks = useTranslations('tasks');
const tErrors = useTranslations('errors');
const { user, isAuthReady } = useAuth();
const { showError, showSuccess } = useToast();
const [session, setSession] = useState<LabCaseAccessSession | null>(null);
const [tasks, setTasks] = useState<LabTaskListItem[]>([]);
const [loading, setLoading] = useState(true);
const [accessDenied, setAccessDenied] = useState(false);
const [updatingTaskId, setUpdatingTaskId] = useState<string | null>(null);
const [prosthesisCatalog, setProsthesisCatalog] = useState<ProsthesisCatalogEntry[]>([]);
const locale = user?.language ?? 'en';
const canEditTasks = session?.canEditTaskStatus ?? false;
const canPostComments = session?.canPostComments ?? false;
const canToggleCommentVisibility = session?.canToggleCommentVisibility ?? false;
const statusOptions: { value: LabTaskStatus; label: string }[] = useMemo(
() => [
{ value: 'IN_PROGRESS', label: tTasks('statusInProgress') },
{ value: 'COMPLETED', label: tTasks('statusCompleted') },
],
[tTasks],
);
const loadData = useCallback(async () => {
setLoading(true);
setAccessDenied(false);
try {
const [sessionRes, tasksRes] = await Promise.all([
labCaseAccessApi.resolve(token),
labCaseAccessApi.listTasks(token),
]);
setSession(sessionRes.data);
setTasks(tasksRes.data.items);
} catch (error: unknown) {
if (asApiError(error)?.code === 'LAB_CASE_ACCESS_DENIED') {
setAccessDenied(true);
} else {
showError(getUserFacingError(error, tErrors, t('accessDenied')));
}
setSession(null);
setTasks([]);
} finally {
setLoading(false);
}
}, [showError, t, tErrors, token]);
useEffect(() => {
if (!isAuthReady || !user) return;
void loadData();
}, [isAuthReady, user, loadData]);
useEffect(() => {
void prosthesisCatalogApi
.list()
.then((response) => setProsthesisCatalog(response.data))
.catch(() => {});
}, []);
const displayModel = useMemo(() => groupTasksForDisplay(tasks, 'date'), [tasks]);
const handleStatusUpdate = useCallback(
async (taskId: string, status: LabTaskStatus) => {
if (!canEditTasks || !user?.id) return;
const task = tasks.find((item) => item.id === taskId);
if (!task || !canEditLabTaskStatus(task, user.id, canEditTasks)) return;
setUpdatingTaskId(taskId);
try {
await tasksApi.updateStatus(taskId, status);
await loadData();
notifyTabBadgesChanged();
if (status === 'COMPLETED') {
showSuccess(tTasks('taskCompletedToast'));
}
} catch (error: unknown) {
showError(getUserFacingError(error, tErrors, tTasks('errorUpdateTask')));
} finally {
setUpdatingTaskId(null);
}
},
[canEditTasks, loadData, showError, showSuccess, tErrors, tTasks, tasks, user?.id],
);
if (!isAuthReady || loading) {
return <p className="text-sm text-text-muted">{t('loading')}</p>;
}
if (accessDenied) {
return (
<div className="rounded-md border border-border bg-background-secondary/40 p-4 sm:p-6 text-center space-y-2 min-w-0">
<p className="text-sm font-medium text-text-primary">{t('accessDeniedTitle')}</p>
<p className="text-sm text-text-muted break-words">{t('accessDenied')}</p>
</div>
);
}
if (!session || displayModel.mode !== 'grouped' || displayModel.cases.length === 0) {
return <p className="text-sm text-text-muted">{t('emptyTasks')}</p>;
}
const caseGroup = displayModel.cases[0];
return (
<div className="space-y-4 min-w-0">
<header className="space-y-1 border-b border-border pb-3 min-w-0">
<h1 className="text-xl sm:text-2xl font-semibold text-text-primary">{t('pageTitle')}</h1>
<p className="text-sm text-text-muted break-words">
{t('caseSummary', {
clinic: session.clinic.name,
patient: `${session.patient.firstName} ${session.patient.lastName}`.trim(),
})}
</p>
{session.lab ? (
<p className="text-sm text-text-muted break-words">
{t('labName', { name: session.lab.name })}
</p>
) : null}
</header>
<section className="surface-card min-h-[280px] min-w-0 overflow-x-hidden">
<section className="border-b border-border last:border-b-0">
<TaskCaseGroupHeader caseGroup={caseGroup} locale={locale} />
{caseGroup.prosthesisGroups.map((prosthesisGroup) => (
<div key={prosthesisGroup.key} className="border-t border-border/50 first:border-t-0">
<TaskProsthesisGroupHeader
group={prosthesisGroup}
prosthesisCatalog={prosthesisCatalog}
/>
<ul>
{prosthesisGroup.tasks.map((task) => (
<TaskRow
key={task.id}
task={task}
locale={locale}
flatMode={false}
canEdit={canEditTasks}
currentUserId={user?.id}
statusOptions={statusOptions}
updatingTaskId={updatingTaskId}
commentsOpen={false}
showCommentsButton={false}
prosthesisCatalog={prosthesisCatalog}
onStatusUpdate={(id, status) => void handleStatusUpdate(id, status)}
onToggleComments={() => {}}
onCommentError={showError}
/>
))}
</ul>
</div>
))}
</section>
</section>
<section className="surface-card p-3 sm:p-4 min-w-0 overflow-x-hidden">
<LabCaseCommentsPanel
caseId={session.labCaseId}
canPost={canPostComments}
canToggleVisibility={canToggleCommentVisibility}
loadComments={async () => {
const r = await labCaseAccessApi.listComments(token);
return r.data;
}}
onPost={async (body, visibleToClinic) => {
const r = await labCaseAccessApi.addComment(token, {
body,
visibleToClinic,
});
notifyTabBadgesChanged();
return r.data;
}}
onToggleVisibility={
canToggleCommentVisibility
? async (commentId, visible) => {
const r = await labCaseAccessApi.setCommentVisibility(
token,
commentId,
visible,
);
return r.data;
}
: undefined
}
onError={showError}
/>
</section>
</div>
);
}

View File

@@ -0,0 +1,21 @@
'use client';
import QRCode from 'react-qr-code';
interface LabCaseShareQrCodeProps {
value: string;
size?: number;
className?: string;
}
export function LabCaseShareQrCode({ value, size = 128, className }: LabCaseShareQrCodeProps) {
return (
<QRCode
value={value}
size={size}
level="M"
className={className}
style={{ height: 'auto', maxWidth: '100%', width: '100%' }}
/>
);
}

View File

@@ -0,0 +1,101 @@
'use client';
import { useCallback, useState } from 'react';
import { useTranslations } from 'next-intl';
import { Copy } from 'lucide-react';
import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
import {
ResponsiveDialogOverlay,
ResponsiveDialogPanel,
} from '@/components/ui/shared/ResponsiveDialog';
import { Button } from '@/components/ui/shared/Button';
import { useToast } from '@/lib/hooks/useToast';
import { LabCaseShareQrCode } from '@/components/ui/lab/LabCaseShareQrCode';
interface LabCaseShareQrDialogProps {
open: boolean;
onClose: () => void;
shareUrl: string;
}
export function LabCaseShareQrDialog({ open, onClose, shareUrl }: LabCaseShareQrDialogProps) {
const t = useTranslations('cases');
const { showSuccess } = useToast();
const [copying, setCopying] = useState(false);
const handleCopy = useCallback(async () => {
setCopying(true);
try {
await navigator.clipboard.writeText(shareUrl);
showSuccess(t('shareLinkCopied'));
} catch {
// ignore clipboard failures
} finally {
setCopying(false);
}
}, [shareUrl, showSuccess, t]);
if (!open) return null;
return (
<ResponsiveDialogOverlay onBackdropClick={onClose}>
<ResponsiveDialogPanel
maxWidthClass="sm:max-w-3xl"
role="dialog"
aria-modal="true"
aria-labelledby="case-share-qr-title"
className="space-y-4"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<h2 id="case-share-qr-title" className="text-lg font-semibold text-text-primary">
{t('shareQrDialogTitle')}
</h2>
<p className="text-sm text-text-muted mt-1">{t('shareQrDialogSubtitle')}</p>
</div>
<DialogCloseButton onClick={onClose} />
</div>
<div className="flex flex-col items-center gap-4 min-w-0">
<div className="w-full max-w-[17rem] sm:max-w-[16rem] rounded-md border border-border bg-white p-3 sm:p-4">
<LabCaseShareQrCode value={shareUrl} size={256} className="h-auto w-full" />
</div>
<p className="w-full min-w-0 break-all text-center text-sm text-text-secondary px-2">
{shareUrl}
</p>
<Button
type="button"
variant="outline"
disabled={copying}
className="w-full sm:w-auto"
onClick={() => void handleCopy()}
>
<Copy className="h-4 w-4 me-1.5" />
{t('copyShareLink')}
</Button>
</div>
</ResponsiveDialogPanel>
</ResponsiveDialogOverlay>
);
}
interface LabCaseShareQrThumbProps {
shareUrl: string;
onClick: () => void;
}
export function LabCaseShareQrThumb({ shareUrl, onClick }: LabCaseShareQrThumbProps) {
const t = useTranslations('cases');
return (
<button
type="button"
onClick={onClick}
className="aspect-square w-24 sm:w-32 cursor-pointer rounded-[var(--radius-md)] border border-border/60 overflow-hidden bg-white p-2 transition-colors hover:border-primary/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary flex items-center justify-center"
title={t('viewShareQr')}
aria-label={t('viewShareQr')}
>
<LabCaseShareQrCode value={shareUrl} size={96} className="h-full w-full max-h-full max-w-full" />
</button>
);
}

View File

@@ -1,6 +1,7 @@
'use client';
import { useTranslations } from 'next-intl';
import { MessageSquare } from 'lucide-react';
import { Badge } from '@/components/ui/shared/Badge';
import { LabCaseDueDateBadge } from '@/components/lab/LabCaseDueDateBadge';
import type { CaseTaskGroup } from '@/components/lab/taskListGrouping';
@@ -14,9 +15,18 @@ function formatPatientName(patient: { firstName: string; lastName: string }) {
interface TaskCaseGroupHeaderProps {
caseGroup: CaseTaskGroup;
locale: string;
showCommentsButton?: boolean;
commentsOpen?: boolean;
onToggleComments?: () => void;
}
export function TaskCaseGroupHeader({ caseGroup, locale }: TaskCaseGroupHeaderProps) {
export function TaskCaseGroupHeader({
caseGroup,
locale,
showCommentsButton = false,
commentsOpen = false,
onToggleComments,
}: TaskCaseGroupHeaderProps) {
const t = useTranslations('tasks');
const progress = countCaseTaskProgress(caseGroup);
@@ -26,7 +36,7 @@ export function TaskCaseGroupHeader({ caseGroup, locale }: TaskCaseGroupHeaderPr
return (
<div className="sticky top-0 z-10 flex flex-wrap items-center justify-between gap-2 border-b border-border/80 bg-background-secondary/95 px-3 py-2.5 backdrop-blur-sm supports-[backdrop-filter]:bg-background-secondary/80">
<div className="min-w-0 space-y-0.5">
<div className="min-w-0 space-y-0.5 flex-1">
<div className="flex flex-wrap items-center gap-1.5">
<p className="text-sm font-semibold text-text-primary truncate">
{t('fromClinic', { name: caseGroup.clinic.name })} ·{' '}
@@ -49,9 +59,26 @@ export function TaskCaseGroupHeader({ caseGroup, locale }: TaskCaseGroupHeaderPr
</p>
) : null}
</div>
<p className="text-xs text-text-muted tabular-nums shrink-0">
{t('caseTaskProgress', { completed: progress.completed, total: progress.total })}
</p>
<div className="flex items-center gap-2 shrink-0">
{showCommentsButton && onToggleComments ? (
<button
type="button"
onClick={onToggleComments}
className={`inline-flex h-9 w-9 items-center justify-center rounded-md border ${
commentsOpen
? 'border-primary bg-primary/10 text-primary'
: 'border-border text-text-muted hover:border-primary/40'
}`}
title={t('commentsButton')}
aria-label={t('commentsButton')}
>
<MessageSquare className="h-4 w-4" />
</button>
) : null}
<p className="text-xs text-text-muted tabular-nums">
{t('caseTaskProgress', { completed: progress.completed, total: progress.total })}
</p>
</div>
</div>
);
}

View File

@@ -33,6 +33,7 @@ interface TaskRowProps {
statusOptions: { value: LabTaskStatus; label: string }[];
updatingTaskId: string | null;
commentsOpen: boolean;
showCommentsButton?: boolean;
prosthesisCatalog: readonly ProsthesisCatalogEntry[];
onStatusUpdate: (taskId: string, status: LabTaskStatus) => void;
onToggleComments: (taskId: string) => void;
@@ -55,6 +56,7 @@ export function TaskRow({
statusOptions,
updatingTaskId,
commentsOpen,
showCommentsButton = true,
prosthesisCatalog,
onStatusUpdate,
onToggleComments,
@@ -78,7 +80,7 @@ export function TaskRow({
.join(' ');
const commentsButton =
canEdit && !exiting ? (
showCommentsButton && canEdit && !exiting ? (
<button
type="button"
onClick={() => onToggleComments(task.id)}

View File

@@ -10,6 +10,7 @@ import { SearchBar } from '@/components/ui/shared/SearchBar';
import { TaskCaseGroupHeader } from '@/components/ui/lab/TaskCaseGroupHeader';
import { TaskProsthesisGroupHeader } from '@/components/ui/lab/TaskProsthesisGroupHeader';
import { TaskRow } from '@/components/ui/lab/TaskRow';
import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel';
import { groupTasksForDisplay } from '@/components/lab/taskListGrouping';
import {
buildDefaultLocateParams,
@@ -64,6 +65,7 @@ export function TasksPage() {
const [updatingTaskId, setUpdatingTaskId] = useState<string | null>(null);
const [exitingTaskIds, setExitingTaskIds] = useState<Set<string>>(() => new Set());
const [expandedCommentsTaskId, setExpandedCommentsTaskId] = useState<string | null>(null);
const [expandedCommentsCaseId, setExpandedCommentsCaseId] = useState<string | null>(null);
const [search, setSearch] = useState(DEFAULT_TASKS_VIEW.search);
const [clinicId, setClinicId] = useState(DEFAULT_TASKS_VIEW.clinicId);
@@ -255,6 +257,7 @@ export function TasksPage() {
setSortBy(DEFAULT_TASKS_VIEW.sortBy);
setSortDir(DEFAULT_TASKS_VIEW.sortDir);
setExpandedCommentsTaskId(null);
setExpandedCommentsCaseId(null);
setHighlightTaskId(task.id);
try {
@@ -357,7 +360,8 @@ export function TasksPage() {
currentUserId={user?.id}
statusOptions={statusOptions}
updatingTaskId={updatingTaskId}
commentsOpen={expandedCommentsTaskId === task.id}
commentsOpen={flatMode && expandedCommentsTaskId === task.id}
showCommentsButton={flatMode}
prosthesisCatalog={prosthesisCatalog}
onStatusUpdate={(id, status) => void handleStatusUpdate(id, status)}
onToggleComments={(id) =>
@@ -519,7 +523,43 @@ export function TasksPage() {
<div className="divide-y divide-border">
{displayModel.cases.map((caseGroup) => (
<section key={caseGroup.labCaseId} className="border-b border-border last:border-b-0">
<TaskCaseGroupHeader caseGroup={caseGroup} locale={locale} />
<TaskCaseGroupHeader
caseGroup={caseGroup}
locale={locale}
showCommentsButton={canEdit}
commentsOpen={expandedCommentsCaseId === caseGroup.labCaseId}
onToggleComments={() =>
setExpandedCommentsCaseId((prev) =>
prev === caseGroup.labCaseId ? null : caseGroup.labCaseId,
)
}
/>
{expandedCommentsCaseId === caseGroup.labCaseId && canEdit ? (
<div className="border-b border-border/50 px-3 pb-3">
<LabCaseCommentsPanel
caseId={caseGroup.labCaseId}
canPost
canToggleVisibility
loadComments={async () => {
const r = await tasksApi.listComments(caseGroup.labCaseId);
return r.data;
}}
onPost={async (body, visibleToClinic) => {
const r = await tasksApi.addComment(caseGroup.labCaseId, {
body,
visibleToClinic,
});
notifyTabBadgesChanged();
return r.data;
}}
onToggleVisibility={async (commentId, visible) => {
const r = await tasksApi.setCommentVisibility(commentId, visible);
return r.data;
}}
onError={showError}
/>
</div>
) : null}
{caseGroup.prosthesisGroups.map((prosthesisGroup) => (
<div
key={prosthesisGroup.key}