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:
@@ -466,7 +466,22 @@
|
||||
"prevPage": "Previous",
|
||||
"nextPage": "Next",
|
||||
"pageSummary": "Page {page} of {totalPages} ({total} cases)",
|
||||
"statusLabel": "Status"
|
||||
"statusLabel": "Status",
|
||||
"viewShareQr": "View case QR code and share link",
|
||||
"shareQrDialogTitle": "Case share link",
|
||||
"shareQrDialogSubtitle": "Scan the QR code or copy the link to open this case’s tasks.",
|
||||
"copyShareLink": "Copy link",
|
||||
"shareLinkCopied": "Link copied to clipboard",
|
||||
"shareQrUnavailable": "QR preview unavailable"
|
||||
},
|
||||
"labCaseAccess": {
|
||||
"pageTitle": "Case tasks",
|
||||
"loading": "Loading case…",
|
||||
"accessDeniedTitle": "Access denied",
|
||||
"accessDenied": "You do not have permission to view this case. Sign in with a lab or clinic account that has access, and make sure you are the treatment provider for clinic access.",
|
||||
"emptyTasks": "No tasks found for this case.",
|
||||
"caseSummary": "{clinic} · {patient}",
|
||||
"labName": "Lab: {name}"
|
||||
},
|
||||
"tasks": {
|
||||
"title": "Tasks",
|
||||
@@ -978,6 +993,7 @@
|
||||
"PERMISSION_ACCESS_TASKS": "You do not have access to tasks.",
|
||||
"PERMISSION_EDIT_TASKS": "You cannot edit tasks.",
|
||||
"PERMISSION_ACCESS_CASES": "You do not have access to cases.",
|
||||
"LAB_CASE_ACCESS_DENIED": "You do not have permission to view this shared case.",
|
||||
"PERMISSION_ACCESS_STAFF": "You do not have access to staff management.",
|
||||
"PERMISSION_EDIT_STAFF": "You cannot manage staff working hours.",
|
||||
"PERMISSION_ORG_NOT_FOUND": "Organization not found.",
|
||||
|
||||
@@ -467,7 +467,22 @@
|
||||
"prevPage": "قبلی",
|
||||
"nextPage": "بعدی",
|
||||
"pageSummary": "صفحه {page} از {totalPages} ({total} پرونده)",
|
||||
"statusLabel": "وضعیت"
|
||||
"statusLabel": "وضعیت",
|
||||
"viewShareQr": "مشاهده QR و لینک اشتراکگذاری پرونده",
|
||||
"shareQrDialogTitle": "لینک اشتراکگذاری پرونده",
|
||||
"shareQrDialogSubtitle": "QR را اسکن کنید یا لینک را کپی کنید تا وظایف این پرونده باز شود.",
|
||||
"copyShareLink": "کپی لینک",
|
||||
"shareLinkCopied": "لینک در کلیپبورد کپی شد",
|
||||
"shareQrUnavailable": "پیشنمایش QR در دسترس نیست"
|
||||
},
|
||||
"labCaseAccess": {
|
||||
"pageTitle": "وظایف پرونده",
|
||||
"loading": "در حال بارگذاری پرونده…",
|
||||
"accessDeniedTitle": "دسترسی مجاز نیست",
|
||||
"accessDenied": "اجازه مشاهده این پرونده را ندارید. با حساب لاب یا کلینیک دارای دسترسی وارد شوید و برای دسترسی کلینیک، ارائهدهنده همان درمان باشید.",
|
||||
"emptyTasks": "وظیفهای برای این پرونده یافت نشد.",
|
||||
"caseSummary": "{clinic} · {patient}",
|
||||
"labName": "لاب: {name}"
|
||||
},
|
||||
"tasks": {
|
||||
"title": "وظایف",
|
||||
@@ -979,6 +994,7 @@
|
||||
"PERMISSION_ACCESS_TASKS": "به وظایف دسترسی ندارید.",
|
||||
"PERMISSION_EDIT_TASKS": "نمیتوانید وظایف را ویرایش کنید.",
|
||||
"PERMISSION_ACCESS_CASES": "به پروندهها دسترسی ندارید.",
|
||||
"LAB_CASE_ACCESS_DENIED": "اجازه مشاهده این پرونده اشتراکگذاریشده را ندارید.",
|
||||
"PERMISSION_ACCESS_STAFF": "به مدیریت پرسنل دسترسی ندارید.",
|
||||
"PERMISSION_EDIT_STAFF": "نمیتوانید ساعات کاری پرسنل را مدیریت کنید.",
|
||||
"PERMISSION_ORG_NOT_FOUND": "سازمان یافت نشد.",
|
||||
|
||||
@@ -467,7 +467,22 @@
|
||||
"prevPage": "Vorige",
|
||||
"nextPage": "Volgende",
|
||||
"pageSummary": "Pagina {page} van {totalPages} ({total} dossiers)",
|
||||
"statusLabel": "Status"
|
||||
"statusLabel": "Status",
|
||||
"viewShareQr": "QR-code en deellink van dossier bekijken",
|
||||
"shareQrDialogTitle": "Deellink dossier",
|
||||
"shareQrDialogSubtitle": "Scan de QR-code of kopieer de link om de taken van dit dossier te openen.",
|
||||
"copyShareLink": "Link kopiëren",
|
||||
"shareLinkCopied": "Link gekopieerd naar klembord",
|
||||
"shareQrUnavailable": "QR-voorbeeld niet beschikbaar"
|
||||
},
|
||||
"labCaseAccess": {
|
||||
"pageTitle": "Dossiertaken",
|
||||
"loading": "Dossier laden…",
|
||||
"accessDeniedTitle": "Geen toegang",
|
||||
"accessDenied": "U hebt geen toestemming om dit dossier te bekijken. Meld u aan met een lab- of kliniekaccount met toegang en zorg dat u de behandelende zorgverlener bent voor kliniektoegang.",
|
||||
"emptyTasks": "Geen taken gevonden voor dit dossier.",
|
||||
"caseSummary": "{clinic} · {patient}",
|
||||
"labName": "Lab: {name}"
|
||||
},
|
||||
"tasks": {
|
||||
"title": "Taken",
|
||||
@@ -978,6 +993,7 @@
|
||||
"PERMISSION_ACCESS_TASKS": "U hebt geen toegang tot taken.",
|
||||
"PERMISSION_EDIT_TASKS": "U kunt taken niet bewerken.",
|
||||
"PERMISSION_ACCESS_CASES": "U hebt geen toegang tot dossiers.",
|
||||
"LAB_CASE_ACCESS_DENIED": "U hebt geen toestemming om dit gedeelde dossier te bekijken.",
|
||||
"PERMISSION_ACCESS_STAFF": "U hebt geen toegang tot personeelsbeheer.",
|
||||
"PERMISSION_EDIT_STAFF": "U kunt werktijden van personeel niet beheren.",
|
||||
"PERMISSION_ORG_NOT_FOUND": "Organisatie niet gevonden.",
|
||||
|
||||
24
frontend/package-lock.json
generated
24
frontend/package-lock.json
generated
@@ -18,6 +18,7 @@
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-hook-form": "^7.71.2",
|
||||
"react-qr-code": "^2.0.15",
|
||||
"recharts": "^3.9.2",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
@@ -5433,7 +5434,6 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
@@ -5844,7 +5844,6 @@
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmmirror.com/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||
@@ -6210,7 +6209,6 @@
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz",
|
||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -6515,7 +6513,6 @@
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmmirror.com/prop-types/-/prop-types-15.8.1.tgz",
|
||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.4.0",
|
||||
@@ -6539,6 +6536,12 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode-generator": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmmirror.com/qrcode-generator/-/qrcode-generator-2.0.4.tgz",
|
||||
"integrity": "sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/queue-microtask": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
@@ -6607,6 +6610,19 @@
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/react-qr-code": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/react-qr-code/-/react-qr-code-2.2.0.tgz",
|
||||
"integrity": "sha512-e5nS0UUN22K3Nf8KBRUzemfdJ6OmnN5w+kbnj1lvJaol9RyVRFeGl05bCkxSN2ZegbLxjjYjX1+mmAoX9+fAhw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prop-types": "^15.8.1",
|
||||
"qrcode-generator": "^2.0.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-redux": {
|
||||
"version": "9.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/react-redux/-/react-redux-9.3.0.tgz",
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-hook-form": "^7.71.2",
|
||||
"react-qr-code": "^2.0.15",
|
||||
"recharts": "^3.9.2",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { CaseTasksFocusView } from '@/components/ui/lab/CaseTasksFocusView';
|
||||
|
||||
interface LabCaseAccessPageProps {
|
||||
params: Promise<{ token: string }>;
|
||||
}
|
||||
|
||||
export default async function LabCaseAccessPage({ params }: LabCaseAccessPageProps) {
|
||||
const { token } = await params;
|
||||
return <CaseTasksFocusView token={token} />;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { useTranslations } from 'next-intl';
|
||||
import { Menu } from 'lucide-react';
|
||||
import { usePathname, useRouter } from '@/i18n/navigation';
|
||||
import { useAuth } from '@/lib/hooks/useAuth';
|
||||
import { storeAuthRedirectFromPath } from '@/lib/auth/postAuthRedirect';
|
||||
import Sidebar from '@/components/ui/shared/Sidebar';
|
||||
import { TopBarControls } from '@/components/ui/shared/TopBarControls';
|
||||
import { DashboardAccountMenu } from '@/components/ui/dashboard/DashboardAccountMenu';
|
||||
@@ -40,7 +41,12 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
||||
if (!isAuthReady) return;
|
||||
|
||||
if (!user) {
|
||||
router.replace('/login');
|
||||
if (pathname && pathname !== '/login') {
|
||||
storeAuthRedirectFromPath(pathname);
|
||||
router.replace(`/login?from=${encodeURIComponent(pathname)}`);
|
||||
} else {
|
||||
router.replace('/login');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { Suspense, useState, useEffect, useMemo } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useRouter } from '@/i18n/navigation';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
@@ -15,6 +16,10 @@ import { AuthPageShell } from '@/components/ui/auth/AuthPageShell';
|
||||
import { Button } from '@/components/ui/shared/Button';
|
||||
import { Checkbox } from '@/components/ui/shared/Checkbox';
|
||||
import { Input } from '@/components/ui/shared/Input';
|
||||
import {
|
||||
consumeAuthRedirect,
|
||||
storeAuthRedirectFromPath,
|
||||
} from '@/lib/auth/postAuthRedirect';
|
||||
|
||||
type LoginForm = {
|
||||
email: string;
|
||||
@@ -22,13 +27,14 @@ type LoginForm = {
|
||||
rememberMe: boolean;
|
||||
};
|
||||
|
||||
export default function LoginPage() {
|
||||
function LoginPageContent() {
|
||||
const t = useTranslations('auth');
|
||||
const tCommon = useTranslations('common');
|
||||
const tValidation = useTranslations('validation');
|
||||
const tErrors = useTranslations('errors');
|
||||
const { login, isLoading, user, isAuthReady } = useAuth();
|
||||
const { login, isLoading, user, isAuthReady, organizations, currentOrganization } = useAuth();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [savedEmail] = useState(() => getRememberedEmail());
|
||||
|
||||
@@ -43,10 +49,18 @@ export default function LoginPage() {
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthReady && user) {
|
||||
router.push('/today');
|
||||
const from = searchParams.get('from');
|
||||
if (from) {
|
||||
storeAuthRedirectFromPath(from);
|
||||
}
|
||||
}, [user, isAuthReady, router]);
|
||||
}, [searchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthReady || !user || isLoading) return;
|
||||
const orgReady = organizations.length <= 1 || currentOrganization;
|
||||
if (!orgReady) return;
|
||||
router.push(consumeAuthRedirect() ?? '/today');
|
||||
}, [isAuthReady, user, isLoading, organizations, currentOrganization, router]);
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -152,3 +166,21 @@ export default function LoginPage() {
|
||||
</AuthPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
function LoginPageFallback() {
|
||||
const tCommon = useTranslations('common');
|
||||
|
||||
return (
|
||||
<div className="min-h-[100dvh] app-web-bg flex items-center justify-center px-4">
|
||||
<p className="text-text-secondary">{tCommon('loading')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<Suspense fallback={<LoginPageFallback />}>
|
||||
<LoginPageContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
216
frontend/src/components/ui/lab/CaseTasksFocusView.tsx
Normal file
216
frontend/src/components/ui/lab/CaseTasksFocusView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
frontend/src/components/ui/lab/LabCaseShareQrCode.tsx
Normal file
21
frontend/src/components/ui/lab/LabCaseShareQrCode.tsx
Normal 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%' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
101
frontend/src/components/ui/lab/LabCaseShareQrDialog.tsx
Normal file
101
frontend/src/components/ui/lab/LabCaseShareQrDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
75
frontend/src/lib/api/lab-case-access.ts
Normal file
75
frontend/src/lib/api/lab-case-access.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { apiClient } from './client';
|
||||
import type { LabCaseComment, LabTaskListItem } from '@/types/cases';
|
||||
|
||||
export interface LabCaseAccessProsthesisGroup {
|
||||
prosthesisTypeCode: string;
|
||||
prosthesisTypeLabel: string;
|
||||
teeth: string[];
|
||||
}
|
||||
|
||||
export interface LabCaseAccessSession {
|
||||
labCaseId: string;
|
||||
accessMode: 'lab' | 'clinic';
|
||||
canEditTaskStatus: boolean;
|
||||
canPostComments: boolean;
|
||||
canToggleCommentVisibility: boolean;
|
||||
shareUrl: string;
|
||||
sentAt: string | null;
|
||||
dueDate: string | null;
|
||||
isOverdue: boolean;
|
||||
isImportant: boolean;
|
||||
clinic: { id: string; name: string };
|
||||
lab: { id: string; name: string } | null;
|
||||
patient: { id: string; firstName: string; lastName: string };
|
||||
prosthesisGroups: LabCaseAccessProsthesisGroup[];
|
||||
}
|
||||
|
||||
export const labCaseAccessApi = {
|
||||
resolve: async (
|
||||
token: string,
|
||||
): Promise<{ success: boolean; data: LabCaseAccessSession }> => {
|
||||
const response = await apiClient.get(`/lab-cases/access/${encodeURIComponent(token)}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
listTasks: async (
|
||||
token: string,
|
||||
): Promise<{ success: boolean; data: { items: LabTaskListItem[] } }> => {
|
||||
const response = await apiClient.get(
|
||||
`/lab-cases/access/${encodeURIComponent(token)}/tasks`,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
listComments: async (
|
||||
token: string,
|
||||
): Promise<{ success: boolean; data: LabCaseComment[] }> => {
|
||||
const response = await apiClient.get(
|
||||
`/lab-cases/access/${encodeURIComponent(token)}/comments`,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
addComment: async (
|
||||
token: string,
|
||||
payload: { body: string; visibleToClinic?: boolean },
|
||||
): Promise<{ success: boolean; data: LabCaseComment }> => {
|
||||
const response = await apiClient.post(
|
||||
`/lab-cases/access/${encodeURIComponent(token)}/comments`,
|
||||
payload,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
setCommentVisibility: async (
|
||||
token: string,
|
||||
commentId: string,
|
||||
visibleToClinic: boolean,
|
||||
): Promise<{ success: boolean; data: LabCaseComment }> => {
|
||||
const response = await apiClient.patch(
|
||||
`/lab-cases/access/${encodeURIComponent(token)}/comments/${commentId}/visibility`,
|
||||
{ visibleToClinic },
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
17
frontend/src/lib/auth/postAuthRedirect.ts
Normal file
17
frontend/src/lib/auth/postAuthRedirect.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { stripLocaleFromPathname } from '@/i18n/routing';
|
||||
|
||||
const AUTH_REDIRECT_KEY = 'authRedirect';
|
||||
|
||||
export function storeAuthRedirectFromPath(pathWithOptionalLocale: string) {
|
||||
if (typeof window === 'undefined') return;
|
||||
const path = stripLocaleFromPathname(pathWithOptionalLocale);
|
||||
if (!path || path === '/login' || path === '/register') return;
|
||||
sessionStorage.setItem(AUTH_REDIRECT_KEY, path);
|
||||
}
|
||||
|
||||
export function consumeAuthRedirect(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
const path = sessionStorage.getItem(AUTH_REDIRECT_KEY);
|
||||
if (path) sessionStorage.removeItem(AUTH_REDIRECT_KEY);
|
||||
return path;
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
startProactiveSessionRefresh,
|
||||
} from '@/lib/auth/proactiveRefresh';
|
||||
import { asApiError, legacyStatusCode, type ApiError } from '@/types/api';
|
||||
import { consumeAuthRedirect } from '@/lib/auth/postAuthRedirect';
|
||||
|
||||
function toApiError(err: unknown): ApiError {
|
||||
return (
|
||||
@@ -288,10 +289,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
await authApi.selectOrganization(org.id);
|
||||
setCurrentOrganization(org);
|
||||
localStorage.setItem('currentOrganizationId', org.id);
|
||||
router.push('/today');
|
||||
} else {
|
||||
router.push('/select-organization');
|
||||
|
||||
}
|
||||
|
||||
} catch (err: any) {
|
||||
@@ -332,7 +331,6 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
await authApi.selectOrganization(org.id);
|
||||
setCurrentOrganization(org);
|
||||
localStorage.setItem('currentOrganizationId', org.id);
|
||||
router.push('/today');
|
||||
} else {
|
||||
router.push('/select-organization');
|
||||
}
|
||||
@@ -388,12 +386,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
plan: (organization as { plan?: Organization['plan'] }).plan,
|
||||
});
|
||||
|
||||
const redirectPath =
|
||||
typeof window !== 'undefined'
|
||||
? sessionStorage.getItem('authRedirect')
|
||||
: null;
|
||||
const redirectPath = consumeAuthRedirect();
|
||||
if (redirectPath) {
|
||||
sessionStorage.removeItem('authRedirect');
|
||||
router.push(redirectPath);
|
||||
} else {
|
||||
router.push('/today');
|
||||
|
||||
@@ -119,6 +119,7 @@ export interface LabCaseDetail {
|
||||
tasks: LabCaseTask[];
|
||||
tasksByTooth: LabCaseTaskGroup[];
|
||||
taskProgress: { completed: number; total: number };
|
||||
shareUrl?: string | null;
|
||||
}
|
||||
|
||||
export interface ListLabCasesParams {
|
||||
|
||||
Reference in New Issue
Block a user