feature: Tasks tab added for lab organizations. tasks now can be assigned and their status can be updated by the assignee.

This commit is contained in:
2026-06-28 22:56:56 +03:30
parent feb0b26ad0
commit 2a48946a51
29 changed files with 851 additions and 43 deletions

View File

@@ -52,6 +52,7 @@
"appointment": "Appointment",
"treatment": "Treatment",
"cases": "Cases",
"tasks": "Tasks",
"billing": "Billing",
"reports": "Reports",
"clinics": "Clinics",
@@ -263,6 +264,7 @@
"featureAppointment": "Appointment",
"featureTreatment": "Treatment",
"featureCases": "Cases",
"featureTasks": "Tasks",
"featureBilling": "Billing",
"featureReports": "Reports",
"noTabAccess": "No tab access",
@@ -348,7 +350,32 @@
"labComment": "Lab comment",
"prevPage": "Previous",
"nextPage": "Next",
"pageSummary": "Page {page} of {totalPages} ({total} cases)"
"pageSummary": "Page {page} of {totalPages} ({total} cases)",
"priorityLabel": "Priority",
"statusLabel": "Status"
},
"tasks": {
"title": "Tasks",
"subtitle": "Your assigned lab tasks. Update status as you work through each step.",
"subtitleOwner": "All lab tasks in the organization. Assign tasks from Cases; update status on your own assignments here.",
"loading": "Loading tasks…",
"emptyList": "No tasks assigned to you yet.",
"emptyListOwner": "No tasks in the lab inbox yet.",
"noPermissionTitle": "Tasks",
"noPermissionBody": "You do not have permission to view tasks for this organization.",
"fromClinic": "From {name}",
"patientLabel": "Patient",
"taskDate": "{date}",
"priorityLabel": "Priority {n}",
"toothLabel": "Tooth {tooth}",
"unassigned": "Unassigned",
"assignedTo": "Assigned to {name}",
"statusPending": "Pending",
"statusInProgress": "In progress",
"statusCompleted": "Completed",
"errorLoadList": "Failed to load tasks.",
"errorUpdateTask": "Failed to update task.",
"pageSummary": "Page {page} of {totalPages} ({total} tasks)"
},
"appointments": {
"title": "Appointments",

View File

@@ -52,6 +52,7 @@
"appointment": "نوبت‌ها",
"treatment": "درمان",
"cases": "پرونده‌ها",
"tasks": "وظایف",
"billing": "صورتحساب",
"reports": "گزارش‌ها",
"clinics": "کلینیک‌ها",
@@ -263,6 +264,7 @@
"featureAppointment": "نوبت‌ها",
"featureTreatment": "درمان",
"featureCases": "پرونده‌ها",
"featureTasks": "وظایف",
"featureBilling": "صورتحساب",
"featureReports": "گزارش‌ها",
"noTabAccess": "دسترسی به برگه‌ها وجود ندارد",
@@ -348,7 +350,32 @@
"labComment": "یادداشت آزمایشگاه",
"prevPage": "قبلی",
"nextPage": "بعدی",
"pageSummary": "صفحه {page} از {totalPages} ({total} پرونده)"
"pageSummary": "صفحه {page} از {totalPages} ({total} پرونده)",
"priorityLabel": "اولویت",
"statusLabel": "وضعیت"
},
"tasks": {
"title": "وظایف",
"subtitle": "وظایف لاب اختصاص‌یافته به شما. وضعیت را در حین انجام هر مرحله به‌روز کنید.",
"subtitleOwner": "همه وظایف لاب در سازمان. تخصیص از بخش پرونده‌ها؛ به‌روزرسانی وضعیت برای وظایف خودتان اینجا.",
"loading": "در حال بارگذاری وظایف…",
"emptyList": "هنوز وظیفه‌ای به شما اختصاص داده نشده است.",
"emptyListOwner": "هنوز وظیفه‌ای در صندوق ورودی لاب وجود ندارد.",
"noPermissionTitle": "وظایف",
"noPermissionBody": "شما مجوز مشاهده وظایف برای این سازمان را ندارید.",
"fromClinic": "از {name}",
"patientLabel": "بیمار",
"taskDate": "{date}",
"priorityLabel": "اولویت {n}",
"toothLabel": "دندان {tooth}",
"unassigned": "اختصاص داده نشده",
"assignedTo": "اختصاص به {name}",
"statusPending": "در انتظار",
"statusInProgress": "در حال انجام",
"statusCompleted": "تکمیل‌شده",
"errorLoadList": "بارگذاری وظایف ناموفق بود.",
"errorUpdateTask": "به‌روزرسانی وظیفه ناموفق بود.",
"pageSummary": "صفحه {page} از {totalPages} ({total} وظیفه)"
},
"appointments": {
"title": "نوبت‌ها",

View File

@@ -52,6 +52,7 @@
"appointment": "Afspraak",
"treatment": "Behandeling",
"cases": "Dossiers",
"tasks": "Taken",
"billing": "Facturatie",
"reports": "Rapporten",
"clinics": "Klinieken",
@@ -263,6 +264,7 @@
"featureAppointment": "Afspraak",
"featureTreatment": "Behandeling",
"featureCases": "Dossiers",
"featureTasks": "Taken",
"featureBilling": "Facturatie",
"featureReports": "Rapporten",
"noTabAccess": "Geen tabbladtoegang",
@@ -348,7 +350,32 @@
"labComment": "Labnotitie",
"prevPage": "Vorige",
"nextPage": "Volgende",
"pageSummary": "Pagina {page} van {totalPages} ({total} dossiers)"
"pageSummary": "Pagina {page} van {totalPages} ({total} dossiers)",
"priorityLabel": "Prioriteit",
"statusLabel": "Status"
},
"tasks": {
"title": "Taken",
"subtitle": "Uw toegewezen labtaken. Werk de status bij terwijl u elke stap uitvoert.",
"subtitleOwner": "Alle labtaken in de organisatie. Wijs toe via Dossiers; werk hier de status bij voor uw eigen taken.",
"loading": "Taken laden…",
"emptyList": "Nog geen taken aan u toegewezen.",
"emptyListOwner": "Nog geen taken in de lab-inbox.",
"noPermissionTitle": "Taken",
"noPermissionBody": "U heeft geen toestemming om taken voor deze organisatie te bekijken.",
"fromClinic": "Van {name}",
"patientLabel": "Patiënt",
"taskDate": "{date}",
"priorityLabel": "Prioriteit {n}",
"toothLabel": "Tand {tooth}",
"unassigned": "Niet toegewezen",
"assignedTo": "Toegewezen aan {name}",
"statusPending": "In afwachting",
"statusInProgress": "Bezig",
"statusCompleted": "Voltooid",
"errorLoadList": "Taken laden mislukt.",
"errorUpdateTask": "Taak bijwerken mislukt.",
"pageSummary": "Pagina {page} van {totalPages} ({total} taken)"
},
"appointments": {
"title": "Afspraken",

View File

@@ -1,14 +1,17 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { ToastStack } from '@/components/ui/shared/Toast';
import { formatApiErrorMessage } from '@/components/shared/formatApiError';
import { useAuth } from '@/lib/hooks/useAuth';
import { useToast } from '@/lib/hooks/useToast';
import { hasPermission } from '@/components/shared/permissions';
import { canEditCases } from '@/components/shared/permissions';
import { Badge, type BadgeVariant } from '@/components/ui/shared/Badge';
import { casesApi } from '@/lib/api/cases';
import { Button } from '@/components/ui/shared/Button';
import { FORM_SELECT_CLASS } from '@/components/ui/shared/formSelectStyles';
import { SearchBar } from '@/components/ui/shared/SearchBar';
import type {
AssignableMember,
@@ -28,6 +31,18 @@ const TREATMENT_TYPE_KEYS = {
} as const;
const PAGE_SIZE = 20;
const PRIORITY_OPTIONS = [1, 2, 3, 4, 5] as const;
function taskStatusVariant(status: LabTaskStatus): BadgeVariant {
switch (status) {
case 'COMPLETED':
return 'success';
case 'IN_PROGRESS':
return 'default';
default:
return 'warning';
}
}
function formatPatientName(patient: { firstName: string; lastName: string }) {
return `${patient.firstName} ${patient.lastName}`.trim();
@@ -66,6 +81,7 @@ export default function CasesPage() {
const tCommon = useTranslations('common');
const { currentOrganization, user } = useAuth();
const toast = useToast();
const searchParams = useSearchParams();
const [search, setSearch] = useState('');
const [clinicId, setClinicId] = useState('');
@@ -93,7 +109,7 @@ export default function CasesPage() {
const [loadingDetail, setLoadingDetail] = useState(false);
const [updatingTaskId, setUpdatingTaskId] = useState<string | null>(null);
const canEdit = hasPermission(currentOrganization, 'TAB_CASES_EDIT');
const canEdit = canEditCases(currentOrganization);
const locale = user?.language ?? 'en';
const treatmentLabel = useCallback(
@@ -166,6 +182,13 @@ export default function CasesPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only initial fetch
}, []);
useEffect(() => {
const caseIdFromUrl = searchParams.get('caseId');
if (caseIdFromUrl) {
setSelectedCaseId(caseIdFromUrl);
}
}, [searchParams]);
useEffect(() => {
const timeout = setTimeout(() => {
void loadCases({
@@ -201,7 +224,7 @@ export default function CasesPage() {
async function handleTaskUpdate(
taskId: string,
payload: { assigneeUserId?: string | null; status?: LabTaskStatus },
payload: { assigneeUserId?: string | null; priority?: number },
) {
if (!selectedCaseId || !canEdit) return;
@@ -225,8 +248,7 @@ export default function CasesPage() {
}
}
const filterSelectClass =
'w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-text-primary';
const filterSelectClass = `${FORM_SELECT_CLASS} w-full rounded-md px-3 py-2`;
return (
<div className="space-y-4">
@@ -475,24 +497,29 @@ export default function CasesPage() {
{group.tasks.map((task) => (
<li
key={task.id}
className="grid gap-2 sm:grid-cols-[1fr_160px_180px] items-center text-sm rounded bg-background p-2"
className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_auto_88px_180px] items-center text-sm rounded bg-background p-2"
>
<span>
{task.stepOrder}. {task.stepLabel}
</span>
<Badge variant={taskStatusVariant(task.status)} fixedWidth={false}>
{statusOptions.find((opt) => opt.value === task.status)?.label ??
task.status}
</Badge>
<select
value={task.status}
value={task.priority}
disabled={!canEdit || updatingTaskId === task.id}
onChange={(e) =>
void handleTaskUpdate(task.id, {
status: e.target.value as LabTaskStatus,
priority: Number(e.target.value),
})
}
className="rounded border border-border bg-surface px-2 py-1 text-sm disabled:opacity-60"
className={FORM_SELECT_CLASS}
aria-label={t('priorityLabel')}
>
{statusOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
{PRIORITY_OPTIONS.map((value) => (
<option key={value} value={value}>
{value}
</option>
))}
</select>
@@ -504,7 +531,7 @@ export default function CasesPage() {
assigneeUserId: e.target.value || null,
})
}
className="rounded border border-border bg-surface px-2 py-1 text-sm disabled:opacity-60"
className={FORM_SELECT_CLASS}
>
<option value="">{t('unassigned')}</option>
{members.map((member) => (

View File

@@ -0,0 +1,258 @@
'use client';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslations } from 'next-intl';
import { ToastStack } from '@/components/ui/shared/Toast';
import { Badge, type BadgeVariant } from '@/components/ui/shared/Badge';
import { Button } from '@/components/ui/shared/Button';
import { FORM_SELECT_CLASS } from '@/components/ui/shared/formSelectStyles';
import { TreatmentTypeBadge } from '@/components/ui/treatment/TreatmentTypeBadge';
import { formatApiErrorMessage } from '@/components/shared/formatApiError';
import { canEditTasks, canViewTasks } from '@/components/shared/permissions';
import { useAuth } from '@/lib/hooks/useAuth';
import { useToast } from '@/lib/hooks/useToast';
import { tasksApi } from '@/lib/api/tasks';
import type { LabTaskListItem, LabTaskStatus, PaginatedLabTasks } from '@/types/cases';
const PAGE_SIZE = 50;
function taskStatusVariant(status: LabTaskStatus): BadgeVariant {
switch (status) {
case 'COMPLETED':
return 'success';
case 'IN_PROGRESS':
return 'default';
default:
return 'warning';
}
}
function formatPatientName(patient: { firstName: string; lastName: string }) {
return `${patient.firstName} ${patient.lastName}`.trim();
}
export default function TasksPage() {
const t = useTranslations('tasks');
const { currentOrganization, user, isAuthReady } = useAuth();
const { showError, setError, messages: toastMessages } = useToast();
const [tasks, setTasks] = useState<LabTaskListItem[]>([]);
const [pagination, setPagination] = useState<PaginatedLabTasks['pagination']>({
page: 1,
limit: PAGE_SIZE,
total: 0,
totalPages: 1,
});
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [updatingTaskId, setUpdatingTaskId] = useState<string | null>(null);
const canView = canViewTasks(currentOrganization);
const canEdit = canEditTasks(currentOrganization);
const locale = user?.language ?? 'en';
const isOwner = Boolean(currentOrganization?.isOwner);
const tRef = useRef(t);
tRef.current = t;
const statusOptions: { value: LabTaskStatus; label: string }[] = useMemo(
() => [
{ value: 'PENDING', label: t('statusPending') },
{ value: 'IN_PROGRESS', label: t('statusInProgress') },
{ value: 'COMPLETED', label: t('statusCompleted') },
],
[t],
);
useEffect(() => {
if (!canView) return;
let cancelled = false;
void (async () => {
setLoading(true);
setError('');
try {
const response = await tasksApi.list({ page, limit: PAGE_SIZE });
if (cancelled) return;
setTasks(response.data.items);
setPagination(response.data.pagination);
} catch (error: unknown) {
if (cancelled) return;
showError(formatApiErrorMessage(error, tRef.current('errorLoadList')));
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [canView, page, showError, setError]);
async function handleStatusUpdate(taskId: string, status: LabTaskStatus) {
if (!canEdit) return;
setUpdatingTaskId(taskId);
setError('');
try {
await tasksApi.updateStatus(taskId, status);
const response = await tasksApi.list({ page, limit: PAGE_SIZE });
setTasks(response.data.items);
setPagination(response.data.pagination);
} catch (error: unknown) {
showError(formatApiErrorMessage(error, t('errorUpdateTask')));
} finally {
setUpdatingTaskId(null);
}
}
function formatTaskDate(value: string) {
return new Intl.DateTimeFormat(locale, {
year: 'numeric',
month: 'short',
day: 'numeric',
}).format(new Date(value));
}
function sortDateForTask(task: LabTaskListItem) {
return task.assignedAt ?? task.createdAt;
}
if (!isAuthReady) {
return <div className="text-sm text-text-muted">{t('loading')}</div>;
}
if (!canView) {
return (
<div className="surface-card p-6 max-w-xl">
<h2 className="text-lg font-semibold text-text-primary">{t('noPermissionTitle')}</h2>
<p className="text-sm text-text-secondary mt-2">{t('noPermissionBody')}</p>
</div>
);
}
return (
<div className="space-y-4">
<header className="space-y-1">
<h1 className="text-2xl font-semibold text-text-primary">{t('title')}</h1>
<p className="text-sm text-text-secondary">
{isOwner ? t('subtitleOwner') : t('subtitle')}
</p>
</header>
<section className="surface-card min-h-[280px]">
{loading && tasks.length === 0 ? (
<p className="p-3 text-sm text-text-muted">{t('loading')}</p>
) : tasks.length === 0 ? (
<p className="p-3 text-sm text-text-muted">
{isOwner ? t('emptyListOwner') : t('emptyList')}
</p>
) : (
<ul className="divide-y divide-border">
{tasks.map((task) => {
const statusEditable =
canEdit && (isOwner || task.assigneeUserId === user?.id);
return (
<li
key={task.id}
className="grid grid-cols-[minmax(0,1fr)_132px_auto] items-center gap-x-3 gap-y-0.5 px-3 py-2"
>
<div className="min-w-0">
<p className="text-sm font-medium text-text-primary truncate">
{task.stepOrder}. {task.stepLabel}
</p>
<p className="text-[11px] text-text-secondary truncate">
{t('fromClinic', { name: task.clinic.name })} ·{' '}
{formatPatientName(task.patient)} · {t('toothLabel', { tooth: task.tooth })}
</p>
<p className="text-[11px] text-text-muted truncate flex flex-wrap items-center gap-x-1 gap-y-0.5">
<span>{t('taskDate', { date: formatTaskDate(sortDateForTask(task)) })}</span>
{isOwner && (
<>
<span aria-hidden>·</span>
<Badge
variant={task.assignee ? 'success' : 'danger'}
fixedWidth={false}
>
{task.assignee
? t('assignedTo', { name: task.assignee.name })
: t('unassigned')}
</Badge>
</>
)}
</p>
</div>
<div className="flex justify-center">
{statusEditable ? (
<select
value={task.status}
disabled={updatingTaskId === task.id}
onChange={(e) =>
void handleStatusUpdate(task.id, e.target.value as LabTaskStatus)
}
className={`${FORM_SELECT_CLASS} w-full max-w-[132px]`}
>
{statusOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
) : (
<Badge variant={taskStatusVariant(task.status)} fixedWidth={false}>
{statusOptions.find((opt) => opt.value === task.status)?.label ??
task.status}
</Badge>
)}
</div>
<div className="flex items-center gap-1.5 shrink-0 justify-end">
<Badge variant="default" fixedWidth={false}>
{t('priorityLabel', { n: task.priority })}
</Badge>
<TreatmentTypeBadge type={task.treatmentType} />
</div>
</li>
);
})}
</ul>
)}
</section>
{pagination.totalPages > 1 && (
<div className="flex items-center justify-between gap-3 flex-wrap">
<p className="text-sm text-text-muted">
{t('pageSummary', {
page: pagination.page,
totalPages: pagination.totalPages,
total: pagination.total,
})}
</p>
<div className="flex gap-2">
<Button
type="button"
variant="secondary"
disabled={page <= 1 || loading}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
</Button>
<Button
type="button"
variant="secondary"
disabled={page >= pagination.totalPages || loading}
onClick={() => setPage((p) => p + 1)}
>
</Button>
</div>
</div>
)}
<ToastStack {...toastMessages} />
</div>
);
}

View File

@@ -16,6 +16,7 @@ export const DASHBOARD_ROUTES: DashboardRouteConfig[] = [
{ prefix: '/appointments', permission: 'TAB_APPOINTMENTS_READ', orgTypes: ['CLINIC'] },
{ prefix: '/treatment', permission: 'TAB_TREATMENT_READ', orgTypes: ['CLINIC'] },
{ prefix: '/cases', permission: 'TAB_CASES_READ', orgTypes: ['LAB'] },
{ prefix: '/tasks', permission: 'TAB_TASKS_READ', orgTypes: ['LAB'] },
{ prefix: '/billing', permission: 'TAB_BILLING_READ', orgTypes: ['CLINIC', 'LAB'] },
{ prefix: '/reports', permission: 'TAB_REPORTS_READ', orgTypes: ['CLINIC', 'LAB'] },
];
@@ -71,6 +72,14 @@ export function canAccessDashboardRoute(org: Organization | null, pathname: stri
return canAccessAppointmentsSection(org);
}
if (route.prefix === '/cases') {
return canViewCases(org);
}
if (route.prefix === '/tasks') {
return canViewTasks(org);
}
return hasPermission(org, route.permission);
}
@@ -84,6 +93,14 @@ export function firstAccessibleDashboardPath(org: Organization | null): string {
if (canAccessAppointmentsSection(org)) return route.prefix;
continue;
}
if (route.prefix === '/cases') {
if (canViewCases(org)) return route.prefix;
continue;
}
if (route.prefix === '/tasks') {
if (canViewTasks(org)) return route.prefix;
continue;
}
if (hasPermission(org, route.permission)) return route.prefix;
}
@@ -175,3 +192,21 @@ export function canEditCases(org: Organization | null): boolean {
if (org.isOwner) return true;
return hasPermission(org, 'TAB_CASES_EDIT');
}
/** Lab task inbox */
export function canViewTasks(org: Organization | null): boolean {
if (!org) return false;
if (org.type !== 'LAB') return false;
if (org.isOwner) return true;
return (
hasPermission(org, 'TAB_TASKS_READ') ||
hasPermission(org, 'TAB_TASKS_EDIT')
);
}
export function canEditTasks(org: Organization | null): boolean {
if (!org) return false;
if (org.type !== 'LAB') return false;
if (org.isOwner) return true;
return hasPermission(org, 'TAB_TASKS_EDIT');
}

View File

@@ -13,6 +13,7 @@ export const STAFF_FEATURE_GROUPS = [
{ labelKey: 'featureAppointment', read: 'TAB_APPOINTMENTS_READ', edit: 'TAB_APPOINTMENTS_EDIT', orgTypes: ['CLINIC'] as const },
{ labelKey: 'featureTreatment', read: 'TAB_TREATMENT_READ', edit: 'TAB_TREATMENT_EDIT', orgTypes: ['CLINIC'] as const },
{ labelKey: 'featureCases', read: 'TAB_CASES_READ', edit: 'TAB_CASES_EDIT', orgTypes: ['LAB'] as const },
{ labelKey: 'featureTasks', read: 'TAB_TASKS_READ', edit: 'TAB_TASKS_EDIT', orgTypes: ['LAB'] as const },
{ labelKey: 'featureBilling', read: 'TAB_BILLING_READ', edit: 'TAB_BILLING_EDIT', orgTypes: ['CLINIC', 'LAB'] as const },
{ labelKey: 'featureReports', read: 'TAB_REPORTS_READ', edit: 'TAB_REPORTS_EDIT', orgTypes: ['CLINIC', 'LAB'] as const },
] as const;

View File

@@ -28,18 +28,13 @@ export const Dropdown = forwardRef<HTMLSelectElement, DropdownProps>(
ref={ref}
id={selectId}
className={`
w-full appearance-none rounded-[var(--radius-md)] border
form-select w-full appearance-none rounded-[var(--radius-md)] border
${error ? 'border-red-500' : 'border-border'}
bg-background-secondary/90 text-text-primary
bg-background-card text-text-primary
pl-4 pr-14 py-2 text-sm
focus:outline-none focus:ring-2 focus:ring-primary/35 focus:border-border-strong
disabled:opacity-50 disabled:cursor-not-allowed
transition-all duration-200 shadow-[inset_0_1px_0_rgba(255,255,255,0.02)]
${className}
`}
{...props}

View File

@@ -12,6 +12,7 @@ import {
FileText,
CreditCard,
Package,
ListTodo,
} from 'lucide-react';
import type { OrgTypeName } from '@/components/shared/permissions';
import { useAuth } from '@/lib/hooks/useAuth';
@@ -19,6 +20,7 @@ import { usePendingConnectionsCount } from '@/lib/hooks/usePendingConnectionsCou
import {
canAccessAppointmentsSection,
canViewCases,
canViewTasks,
canViewTab,
} from '@/components/shared/permissions';
import {
@@ -55,6 +57,7 @@ function Sidebar() {
},
{ name: t('patients'), path: '/patients', icon: Users, read: 'TAB_PATIENTS_READ', orgTypes: ['CLINIC'] },
{ name: t('cases'), path: '/cases', icon: Package, read: 'TAB_CASES_READ', orgTypes: ['LAB'] },
{ name: t('tasks'), path: '/tasks', icon: ListTodo, read: 'TAB_TASKS_READ', orgTypes: ['LAB'] },
{ name: t('appointment'), path: '/appointments', icon: Calendar, read: 'TAB_APPOINTMENTS_READ', orgTypes: ['CLINIC'] },
{ name: t('treatment'), path: '/treatment', icon: FlaskConical, read: 'TAB_TREATMENT_READ', orgTypes: ['CLINIC'] },
{ name: t('billing'), path: '/billing', icon: CreditCard, read: 'TAB_BILLING_READ', orgTypes: ['CLINIC', 'LAB'] },
@@ -75,6 +78,9 @@ function Sidebar() {
if (item.path === '/cases') {
return canViewCases(currentOrganization);
}
if (item.path === '/tasks') {
return canViewTasks(currentOrganization);
}
return canViewTab(currentOrganization, item.read);
}),
[currentOrganization, menu, orgType],

View File

@@ -0,0 +1,3 @@
/** Shared native select styling — readable in light and dark themes */
export const FORM_SELECT_CLASS =
'form-select rounded border border-border bg-background-card text-text-primary px-2 py-1 text-sm disabled:opacity-60 focus:outline-none focus:ring-2 focus:ring-primary/35';

View File

@@ -34,7 +34,7 @@ export const casesApi = {
updateTask: async (
caseId: string,
taskId: string,
payload: { assigneeUserId?: string | null; status?: LabCaseTask['status'] },
payload: { assigneeUserId?: string | null; priority?: number },
): Promise<{ success: boolean; data: LabCaseTask }> => {
const response = await apiClient.patch(`/cases/${caseId}/tasks/${taskId}`, payload);
return response.data;

View File

@@ -0,0 +1,20 @@
import { apiClient } from './client';
import type { LabTaskListItem, LabTaskStatus, PaginatedLabTasks } from '@/types/cases';
export const tasksApi = {
list: async (params: { page?: number; limit?: number } = {}): Promise<{
success: boolean;
data: PaginatedLabTasks;
}> => {
const response = await apiClient.get('/tasks', { params });
return response.data;
},
updateStatus: async (
taskId: string,
status: LabTaskStatus,
): Promise<{ success: boolean; data: LabTaskListItem }> => {
const response = await apiClient.patch(`/tasks/${taskId}`, { status });
return response.data;
},
};

View File

@@ -247,6 +247,24 @@ body {
font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
}
select.form-select,
select {
color: var(--color-text-primary);
background-color: var(--color-background-card);
}
select option {
color: var(--color-text-primary);
background-color: var(--color-background-secondary);
}
:root[data-theme='dark'] select.form-select,
:root[data-theme='dark'] select,
:root:not([data-theme='light']) select.form-select,
:root:not([data-theme='light']) select {
color-scheme: dark;
}
.surface-card {
background: color-mix(in srgb, var(--color-card-background) 92%, transparent);
border: 1px solid var(--color-card-border);

View File

@@ -21,6 +21,9 @@ export interface LabCaseTask {
stepOrder: number;
stepLabel: string;
status: LabTaskStatus;
priority: number;
assignedAt: string | null;
createdAt: string;
assigneeUserId: string | null;
assignee: { id: string; name: string; email: string } | null;
}
@@ -91,3 +94,30 @@ export interface PaginatedLabCases {
totalPages: number;
};
}
export interface LabTaskListItem {
id: string;
labCaseId: string;
tooth: string;
treatmentType: string;
stepOrder: number;
stepLabel: string;
status: LabTaskStatus;
priority: number;
assignedAt: string | null;
createdAt: string;
assigneeUserId: string | null;
assignee: { id: string; name: string; email: string } | null;
clinic: { id: string; name: string };
patient: { id: string; firstName: string; lastName: string };
}
export interface PaginatedLabTasks {
items: LabTaskListItem[];
pagination: {
page: number;
limit: number;
total: number;
totalPages: number;
};
}