Merge branch 'master' into feature/tab-warning-flag

This commit is contained in:
2026-06-22 11:20:17 +03:30
92 changed files with 6547 additions and 1535 deletions

View File

@@ -0,0 +1,353 @@
'use client';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslations } from 'next-intl';
import { appointmentsApi } from '@/lib/api/appointments';
import { patientsApi } from '@/lib/api/patients';
import { useAuth } from '@/lib/hooks/useAuth';
import { canEditAppointments, hasPermission } from '@/components/shared/permissions';
import type { AppointmentColumnProvider, AppointmentRecord } from '@/types/appointment';
import type { CreatePatientInput, Patient } from '@/types/patient';
import { CreatePatientModal } from '@/components/ui/patient/CreatePatientModal';
import { PatientSummaryCard } from '@/components/ui/patient/PatientSummaryCard';
import { AppointmentBookingModal } from '@/components/ui/appointments/AppointmentBookingModal';
import { AppointmentScheduleGrid } from '@/components/ui/appointments/AppointmentScheduleGrid';
import { AppointmentsPatientSearch } from '@/components/ui/appointments/AppointmentsPatientSearch';
import { AppointmentScheduleLegend } from '@/components/ui/appointments/AppointmentScheduleLegend';
import { ScheduleDayPicker } from '@/components/ui/shared/ScheduleDayPicker';
import { ToastStack } from '@/components/ui/shared/Toast';
import { useToast } from '@/lib/hooks/useToast';
import type { AppointmentPurpose } from '@/types/appointment';
import { formatApiErrorMessage } from '@/components/shared/formatApiError';
import { compareLocalDayStart, getLocalDayIsoRange, startOfLocalDay } from '@/components/appointments/appointmentTime';
const EMPTY_PATIENT_FORM: CreatePatientInput = {
firstName: '',
lastName: '',
phone: '',
email: '',
};
export default function AppointmentsPage() {
const t = useTranslations('appointments');
const tPatients = useTranslations('patients');
const { currentOrganization } = useAuth();
const [scheduleDate, setScheduleDate] = useState(() => startOfLocalDay(new Date()));
const [providers, setProviders] = useState<AppointmentColumnProvider[]>([]);
const [appointments, setAppointments] = useState<AppointmentRecord[]>([]);
const [loadingSchedule, setLoadingSchedule] = useState(false);
const toast = useToast();
const [search, setSearch] = useState('');
const [patients, setPatients] = useState<Patient[]>([]);
const [selectedPatient, setSelectedPatient] = useState<Patient | undefined>();
const [loadingPatients, setLoadingPatients] = useState(false);
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [savingPatient, setSavingPatient] = useState(false);
const [patientForm, setPatientForm] = useState<CreatePatientInput>(EMPTY_PATIENT_FORM);
const [bookingOpen, setBookingOpen] = useState(false);
const [bookingStartMinute, setBookingStartMinute] = useState(9 * 60);
const [bookingProviderId, setBookingProviderId] = useState<string | null>(null);
const [bookingProviderName, setBookingProviderName] = useState('');
const [editingAppointmentId, setEditingAppointmentId] = useState<string | null>(null);
const [savingAppointment, setSavingAppointment] = useState(false);
const [deletingAppointment, setDeletingAppointment] = useState(false);
const canManageAppointments = canEditAppointments(currentOrganization);
const canEditPatients = hasPermission(currentOrganization, 'TAB_PATIENTS_EDIT');
const todayStart = useMemo(() => startOfLocalDay(new Date()), []);
const isViewingPastDay = useMemo(
() => compareLocalDayStart(scheduleDate, todayStart) < 0,
[scheduleDate, todayStart],
);
const activeEditingAppointment = useMemo(
() => appointments.find((a) => a.id === editingAppointmentId) ?? null,
[appointments, editingAppointmentId],
);
const scheduleLoadGen = useRef(0);
const sortedPatients = useMemo(
() =>
[...patients].sort((a, b) =>
`${a.firstName} ${a.lastName}`.localeCompare(`${b.firstName} ${b.lastName}`),
),
[patients],
);
const loadSchedule = useCallback(async () => {
if (!currentOrganization?.id) {
return;
}
const gen = ++scheduleLoadGen.current;
setLoadingSchedule(true);
toast.setError('');
try {
const range = getLocalDayIsoRange(scheduleDate);
const [pRes, aRes] = await Promise.all([
appointmentsApi.columnProviders(scheduleDate),
appointmentsApi.list(range),
]);
if (gen !== scheduleLoadGen.current) {
return;
}
setProviders(pRes.data);
setAppointments(aRes.data);
} catch (err: unknown) {
if (gen !== scheduleLoadGen.current) {
return;
}
toast.showError(formatApiErrorMessage(err, t('errorLoadSchedule')));
} finally {
if (gen === scheduleLoadGen.current) {
setLoadingSchedule(false);
}
}
}, [currentOrganization?.id, scheduleDate, t]);
useEffect(() => {
void loadSchedule();
}, [loadSchedule]);
useEffect(() => {
const t = setTimeout(() => {
void loadPatientsSearch(search);
}, 300);
return () => clearTimeout(t);
}, [search]);
async function loadPatientsSearch(q: string) {
if (!currentOrganization) {
return;
}
setLoadingPatients(true);
try {
const response = await patientsApi.list({ q, page: 1, limit: 25 });
const items = response.data.items;
setPatients(items);
if (selectedPatient) {
const stillThere = items.find((p) => p.id === selectedPatient.id);
if (stillThere) {
setSelectedPatient(stillThere);
}
}
} catch {
setPatients([]);
} finally {
setLoadingPatients(false);
}
}
async function handleCreatePatient() {
setSavingPatient(true);
toast.setError('');
try {
const response = await patientsApi.create(patientForm);
setIsCreateOpen(false);
setPatientForm(EMPTY_PATIENT_FORM);
await loadPatientsSearch(search);
setSelectedPatient(response.data);
toast.showSuccess(
t('successPatientSaved', {
firstName: response.data.firstName,
lastName: response.data.lastName,
}),
);
} catch (err: unknown) {
const message =
err && typeof err === 'object' && 'message' in err
? String((err as { message: unknown }).message)
: tPatients('errorSavePatient');
toast.showError(message);
} finally {
setSavingPatient(false);
}
}
function handleSlotClick(startMinute: number, providerUserId: string, providerName: string) {
if (isViewingPastDay) {
toast.showInfo(t('infoPastViewOnly'));
return;
}
if (!selectedPatient) {
toast.showInfo(t('infoSelectPatient'));
return;
}
setBookingStartMinute(startMinute);
setBookingProviderId(providerUserId);
setBookingProviderName(providerName);
setEditingAppointmentId(null);
setBookingOpen(true);
}
function handleAppointmentClick(appointment: AppointmentRecord) {
if (isViewingPastDay) {
toast.showInfo(t('infoPastViewOnly'));
return;
}
const provider = providers.find((p) => p.userId === appointment.providerUserId);
const start = new Date(appointment.startAt);
setBookingStartMinute(start.getHours() * 60 + start.getMinutes());
setBookingProviderId(appointment.providerUserId);
setBookingProviderName(provider?.name ?? bookingProviderName);
setEditingAppointmentId(appointment.id);
setBookingOpen(true);
}
function handleAppointmentOutsideHours(appointment: AppointmentRecord) {
toast.showError(t('errorOutsideHours'));
}
async function handleSaveAppointment(payload: {
patientId: string;
providerUserId: string;
startAt: string;
endAt: string;
purpose: AppointmentPurpose;
}) {
setSavingAppointment(true);
toast.setError('');
try {
if (activeEditingAppointment) {
await appointmentsApi.update(activeEditingAppointment.id, payload);
} else {
await appointmentsApi.create(payload);
}
setBookingOpen(false);
setEditingAppointmentId(null);
toast.showSuccess(activeEditingAppointment ? t('successUpdated') : t('successSaved'));
await loadSchedule();
} catch (err: unknown) {
const message =
err && typeof err === 'object' && 'message' in err
? String((err as { message: unknown }).message)
: activeEditingAppointment
? t('errorUpdate')
: t('errorSave');
toast.showError(message);
} finally {
setSavingAppointment(false);
}
}
async function handleDeleteEditingAppointment() {
if (!activeEditingAppointment) {
return;
}
if (!window.confirm(t('confirmRemove'))) {
return;
}
setDeletingAppointment(true);
toast.setError('');
try {
await appointmentsApi.remove(activeEditingAppointment.id);
setBookingOpen(false);
setEditingAppointmentId(null);
toast.showSuccess(t('successRemoved'));
await loadSchedule();
} catch (err: unknown) {
const message =
err && typeof err === 'object' && 'message' in err
? String((err as { message: unknown }).message)
: t('errorDelete');
toast.showError(message);
} finally {
setDeletingAppointment(false);
}
}
return (
<div className="space-y-6">
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-semibold text-text-primary">{t('title')}</h1>
<p className="text-sm text-text-secondary">{t('subtitle')}</p>
</div>
<ToastStack {...toast.messages} />
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
<div className="xl:col-span-1 space-y-4">
<AppointmentsPatientSearch
search={search}
onSearchChange={setSearch}
patients={sortedPatients}
selectedPatientId={selectedPatient?.id}
onSelectPatient={setSelectedPatient}
loading={loadingPatients}
canAddPatient={canEditPatients}
onAddPatient={() => {
if (!canEditPatients) {
return;
}
setPatientForm(EMPTY_PATIENT_FORM);
setIsCreateOpen(true);
}}
/>
<PatientSummaryCard patient={selectedPatient} />
</div>
<div className="xl:col-span-2 space-y-4">
<AppointmentScheduleLegend />
<div className="flex flex-col sm:flex-row sm:items-end gap-4 sm:justify-between">
<ScheduleDayPicker
value={scheduleDate}
onChange={(d) => setScheduleDate(startOfLocalDay(d))}
/>
{loadingSchedule && (
<p className="text-sm text-text-muted pb-2">{t('loadingSchedule')}</p>
)}
</div>
<AppointmentScheduleGrid
day={scheduleDate}
providers={providers}
appointments={appointments}
canBook={canManageAppointments && !isViewingPastDay}
onSlotClick={(startMinute, uid, name) => handleSlotClick(startMinute, uid, name)}
onAppointmentClick={(apt) => handleAppointmentClick(apt)}
onAppointmentOutsideHours={(apt) => handleAppointmentOutsideHours(apt)}
/>
</div>
</div>
<AppointmentBookingModal
open={bookingOpen}
scheduleDate={scheduleDate}
patient={selectedPatient}
providerUserId={bookingProviderId}
providerName={bookingProviderName}
initialStartMinute={bookingStartMinute}
editingAppointment={activeEditingAppointment}
onClose={() => {
setBookingOpen(false);
setEditingAppointmentId(null);
}}
onSubmit={handleSaveAppointment}
loading={savingAppointment}
canDelete={canManageAppointments && !isViewingPastDay && !!activeEditingAppointment}
onDelete={() => void handleDeleteEditingAppointment()}
deleting={deletingAppointment}
/>
<CreatePatientModal
variant="dialog"
isOpen={isCreateOpen}
formData={patientForm}
onChange={(patch) => setPatientForm((prev) => ({ ...prev, ...patch }))}
onSubmit={() => void handleCreatePatient()}
onClose={() => {
setIsCreateOpen(false);
setPatientForm(EMPTY_PATIENT_FORM);
}}
loading={savingPatient}
/>
</div>
);
}

View File

@@ -0,0 +1,214 @@
// src/app/(dashboard)/billing/page.tsx
'use client';
import { useState } from 'react';
import { Pencil } from 'lucide-react';
import { Button } from '@/components/ui/shared/Button';
import { Badge } from '@/components/ui/shared/Badge';
import { Card } from '@/components/ui/shared/Card';
import { Table } from '@/components/ui/shared/Table';
import { SearchBar } from '@/components/ui/shared/SearchBar';
import { useAuth } from '@/lib/hooks/useAuth';
import { hasPermission } from '@/components/shared/permissions';
// Mock data matching your design
const invoices = [
{ id: '#123456', patient: 'Ali Rahmani', date: '24/9/2026', service: 'Hygiene', amount: 300, paid: 0, status: 'unpaid' },
{ id: '#123457', patient: 'Neda Akbari', date: '01/10/2026', service: 'Filling', amount: 700, paid: 400, status: 'overdue' },
{ id: '#123458', patient: 'Nima Haghi', date: '09/12/2026', service: 'Extraction', amount: 450, paid: 450, status: 'paid' },
];
const statusColors = {
paid: 'success',
unpaid: 'warning',
overdue: 'danger',
} as const;
type StatCardColor = 'blue' | 'yellow' | 'green' | 'red';
interface StatCardProps {
title: string;
count: number;
amount: number;
color: StatCardColor;
}
export default function BillingPage() {
const { currentOrganization } = useAuth();
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState('all');
const canEditBilling = hasPermission(currentOrganization, 'TAB_BILLING_EDIT');
const stats = {
total: { count: 235, amount: 80900 },
unpaid: { count: 30, amount: 2800 },
paid: { count: 190, amount: 80900 },
overdue: { count: 235, amount: 80900 },
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex justify-between items-center">
<h1 className="text-2xl font-semibold text-text-primary">Billing</h1>
<Button
variant="primary"
disabled={!canEditBilling}
title={!canEditBilling ? 'Read-only access for this organization.' : undefined}
>
New Invoice
</Button>
</div>
{/* Stats Cards - Matching your design */}
<div className="grid grid-cols-4 gap-4">
<StatCard
title="Total Invoices"
count={stats.total.count}
amount={stats.total.amount}
color="blue"
/>
<StatCard
title="Unpaid Invoices"
count={stats.unpaid.count}
amount={stats.unpaid.amount}
color="yellow"
/>
<StatCard
title="Paid Invoices"
count={stats.paid.count}
amount={stats.paid.amount}
color="green"
/>
<StatCard
title="Overdue Invoices"
count={stats.overdue.count}
amount={stats.overdue.amount}
color="red"
/>
</div>
{/* Filters */}
<SearchBar
value={search}
onChange={setSearch}
placeholder="Search patients..."
actions={(
<>
{['all', 'paid', 'unpaid', 'overdue'].map((status) => (
<button
key={status}
onClick={() => setStatusFilter(status)}
className={`px-4 py-2 rounded-[var(--radius-sm)] text-sm font-medium capitalize border ${statusFilter === status
? 'bg-primary-soft text-primary border-primary/50'
: 'text-text-secondary border-border/40 hover:bg-background-card/70 hover:border-border'
}`}
>
{status}
</button>
))}
</>
)}
/>
{/* Invoices Table - Matching your design */}
<Table
headers={
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
Invoice ID
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
Patient name
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
Date
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
Service
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
Total amount
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
Paid
</th>
<th className="px-6 py-3 text-center text-xs font-medium text-text-muted uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
Action
</th>
</tr>
}
body={
<>
{invoices.map((invoice) => (
<tr key={invoice.id} className="hover:bg-background-secondary/45">
<td className="px-6 py-1.5 text-sm font-medium text-text-primary">
{invoice.id}
</td>
<td className="px-6 py-1.5 text-sm text-text-primary">
{invoice.patient}
</td>
<td className="px-6 py-1.5 text-sm text-text-secondary">
{invoice.date}
</td>
<td className="px-6 py-1.5 text-sm text-text-primary">
{invoice.service}
</td>
<td className="px-6 py-1.5 text-sm text-text-primary">
${invoice.amount}
</td>
<td className="px-6 py-1.5 text-sm text-text-primary">
${invoice.paid}
</td>
<td className="px-6 py-1.5 text-center align-middle">
<Badge
variant={statusColors[invoice.status as keyof typeof statusColors]}
className="capitalize"
>
{invoice.status}
</Badge>
</td>
<td className="px-6 py-1.5">
<button
className={`p-2 rounded-md ${canEditBilling
? 'text-text-secondary hover:bg-background-card/80 hover:text-text-primary'
: 'text-text-muted cursor-not-allowed opacity-50'}`}
disabled={!canEditBilling}
title={!canEditBilling ? 'Read-only access for this organization.' : undefined}
aria-label="Edit invoice"
>
<Pencil className="w-4 h-4" />
</button>
</td>
</tr>
))}
</>
}
footer={(
<>
<button className="text-sm text-text-secondary hover:text-text-primary">
Previous
</button>
<div className="text-sm text-text-secondary">
Page 1 of 10
</div>
<button className="text-sm text-text-secondary hover:text-text-primary">
Next
</button>
</>
)}
/>
</div>
);
}
function StatCard({ title, count, amount, color }: StatCardProps) {
const colors: Record<StatCardColor, string> = {
blue: '!bg-purpose-visit-bg !text-purpose-visit-fg !border-purpose-visit-border',
yellow: '!bg-badge-warning-bg !text-badge-warning-fg !border-badge-warning-border',
green: '!bg-badge-success-bg !text-badge-success-fg !border-badge-success-border',
red: '!bg-badge-danger-bg !text-badge-danger-fg !border-badge-danger-border',
};
return (
<Card className={`${colors[color]}`}>
<p className="text-sm font-medium">{title}</p>
<p className="text-2xl font-bold mt-1">{count}</p>
<p className="text-sm font-medium mt-1">
${amount.toLocaleString()}
</p>
</Card>
);
}

View File

@@ -0,0 +1,96 @@
'use client';
import { memo, useEffect } from 'react';
import { useTranslations } from 'next-intl';
import { usePathname, useRouter } from '@/i18n/navigation';
import { useAuth } from '@/lib/hooks/useAuth';
import Sidebar from '@/components/ui/shared/Sidebar';
import { TopBarControls } from '@/components/ui/shared/TopBarControls';
import { DashboardAccountMenu } from '@/components/ui/dashboard/DashboardAccountMenu';
import {
canAccessAppointmentsSection,
firstAccessibleDashboardPath,
getRequiredReadPermissionForPath,
hasPermission,
} from '@/components/shared/permissions';
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
const t = useTranslations('common');
const { user, currentOrganization, isAuthReady } = useAuth();
const router = useRouter();
const pathname = usePathname();
useEffect(() => {
if (!isAuthReady) return;
if (!user) {
router.replace('/login');
return;
}
if (!currentOrganization) {
router.replace('/select-organization');
return;
}
const required = getRequiredReadPermissionForPath(pathname);
if (required) {
const allowed =
hasPermission(currentOrganization, required) ||
(required === 'TAB_APPOINTMENTS_READ' &&
canAccessAppointmentsSection(currentOrganization));
if (!allowed) {
router.replace(firstAccessibleDashboardPath(currentOrganization));
}
}
}, [isAuthReady, user, currentOrganization, router, pathname]);
if (!isAuthReady) {
return (
<div className="h-screen flex items-center justify-center app-web-bg">
{t('loadingApp')}
</div>
);
}
if (!user || !currentOrganization) {
return (
<div className="h-screen flex items-center justify-center app-web-bg">
{t('loadingWorkspace')}
</div>
);
}
return (
<div className="flex h-screen app-web-bg text-text-primary">
<Sidebar />
<div className="flex-1 flex flex-col">
<DashboardHeader organizationName={currentOrganization.name} />
<main className="p-6 flex-1 overflow-y-auto">
<div className="surface-panel p-6 min-h-full">
{children}
</div>
</main>
</div>
</div>
);
}
const DashboardHeader = memo(function DashboardHeader({
organizationName,
}: {
organizationName: string;
}) {
return (
<header className="relative z-40 h-[71px] flex justify-between items-center gap-4 px-6 border-b border-border/70 backdrop-blur-sm">
<h2 className="text-lg font-medium truncate min-w-0">{organizationName}</h2>
<div className="flex items-center gap-3 shrink-0">
<TopBarControls />
<DashboardAccountMenu />
</div>
</header>
);
});

View File

@@ -0,0 +1,529 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { useToast } from '@/lib/hooks/useToast';
import { Check, Trash2, UserPlus, X } from 'lucide-react';
import { useAuth } from '@/lib/hooks/useAuth';
import { notifyPendingConnectionsChanged } from '@/lib/hooks/usePendingConnectionsCount';
import { useOrganizationInviteLinkCopy } from '@/lib/hooks/useOrganizationInviteLinkCopy';
import {
organizationApi,
type CounterpartItemDto,
type CounterpartSearchResultDto,
type OrganizationInvitationHistoryItemDto,
} from '@/lib/api/organization';
import { invitationTargetFromConnectionRow } from '@/components/invitations/organizationInviteLinks';
import { CopyInvitationLinkButton } from '@/components/ui/organizations/CopyInvitationLinkButton';
import { InvitationHistoryDialog } from '@/components/ui/organizations/InvitationHistoryDialog';
import { Button } from '@/components/ui/shared/Button';
import { Badge, organizationConnectionStatusVariant } from '@/components/ui/shared/Badge';
import { Input } from '@/components/ui/shared/Input';
import { SearchBar } from '@/components/ui/shared/SearchBar';
import { Table } from '@/components/ui/shared/Table';
import { ToastStack } from '@/components/ui/shared/Toast';
import type { ApiError } from '@/types/api';
function formatOrganizationStatusLabel(status: string): string {
if (!status) return status;
const lower = status.toLowerCase();
return lower.charAt(0).toUpperCase() + lower.slice(1);
}
function formatTableDate(value: string): string {
const d = new Date(value);
if (Number.isNaN(d.getTime())) return '\u2014';
return d.toLocaleDateString();
}
type TableMode = 'existing' | 'search';
export default function OrganizationsPage() {
const t = useTranslations('organizations');
const tNav = useTranslations('nav');
const tCommon = useTranslations('common');
const { currentOrganization } = useAuth();
const [loading, setLoading] = useState(true);
const toast = useToast();
const formatApiMessage = useCallback(
(err: unknown): string => {
if (!err || typeof err !== 'object') return tCommon('errorGeneric');
const m = (err as ApiError).message;
if (Array.isArray(m)) return m.join(', ');
if (typeof m === 'string') return m;
return tCommon('errorGeneric');
},
[tCommon],
);
const formatConnectionStatusLabel = useCallback(
(row: CounterpartItemDto, currentOrganizationId: string): string => {
if (row.status === 'PENDING') {
if (
row.pendingInvitationId &&
row.requestedByOrganizationId === currentOrganizationId
) {
return t('statusInvitationPending');
}
return t('statusConnectionPending');
}
if (row.status === 'ACTIVE') return t('statusConnected');
if (row.status === 'REJECTED') return t('statusDeclined');
return formatOrganizationStatusLabel(row.status);
},
[t],
);
const [query, setQuery] = useState('');
const [mode, setMode] = useState<TableMode>('existing');
const [searching, setSearching] = useState(false);
const [searchResults, setSearchResults] = useState<CounterpartSearchResultDto[]>([]);
const [pendingConnectionRowId, setPendingConnectionRowId] = useState<string | null>(null);
const [deleteConnectionRowId, setDeleteConnectionRowId] = useState<string | null>(null);
const [items, setItems] = useState<CounterpartItemDto[]>([]);
const [manualOrganizationName, setManualOrganizationName] = useState('');
const [manualOwnerEmail, setManualOwnerEmail] = useState('');
const [inviteLoading, setInviteLoading] = useState(false);
const [showInviteForm, setShowInviteForm] = useState(false);
const [historyOpen, setHistoryOpen] = useState(false);
const [historyLoading, setHistoryLoading] = useState(false);
const [historyItems, setHistoryItems] = useState<OrganizationInvitationHistoryItemDto[]>([]);
const {
copiedId,
copyingInvitationId,
storeInviteLink,
copyInvitationLink,
pruneAcceptedLinks,
} = useOrganizationInviteLinkCopy(currentOrganization?.id);
const counterpart =
currentOrganization?.type === 'LAB' ? t('counterpartClinic') : t('counterpartLab');
const tabLabel = currentOrganization?.type === 'LAB' ? tNav('clinics') : tNav('labs');
const existingRows = items;
async function loadList() {
setLoading(true);
toast.setError('');
try {
const res = await organizationApi.list();
setItems(res.data.items);
} catch (e) {
toast.showError(formatApiMessage(e));
} finally {
setLoading(false);
}
}
useEffect(() => {
void loadList();
}, []);
async function runSearch() {
const q = query.trim();
if (!q) {
setMode('existing');
setSearchResults([]);
setShowInviteForm(false);
return;
}
setSearching(true);
toast.setError('');
setMode('search');
setShowInviteForm(false);
try {
const res = await organizationApi.search(q);
setSearchResults(res.data);
} catch (e) {
toast.showError(formatApiMessage(e));
setSearchResults([]);
} finally {
setSearching(false);
}
}
async function submitConnectionRequest(targetOrganizationId: string) {
setPendingConnectionRowId(targetOrganizationId);
toast.setError('');
try {
await organizationApi.createConnectionRequest(targetOrganizationId);
toast.showSuccess(t('successConnectionSent', { counterpart }));
setSearchResults([]);
setQuery('');
setMode('existing');
await loadList();
} catch (e) {
toast.showError(formatApiMessage(e));
} finally {
setPendingConnectionRowId(null);
}
}
async function sendInvite() {
setInviteLoading(true);
toast.setError('');
try {
const res = await organizationApi.invite({
organizationName: manualOrganizationName.trim(),
ownerEmail: manualOwnerEmail.trim(),
});
storeInviteLink(res.data.invitationId, manualOwnerEmail, res.data.invitationUrl);
toast.showSuccess(t('successInviteCreated', { email: manualOwnerEmail.trim() }));
setManualOrganizationName('');
setManualOwnerEmail('');
setShowInviteForm(false);
setMode('existing');
setQuery('');
setSearchResults([]);
await loadList();
} catch (e) {
toast.showError(formatApiMessage(e));
} finally {
setInviteLoading(false);
}
}
async function loadInvitationHistory() {
const res = await organizationApi.listInvitations();
setHistoryItems(res.data.items);
pruneAcceptedLinks(res.data.items);
return res.data.items;
}
async function openInvitationHistory() {
setHistoryOpen(true);
setHistoryLoading(true);
toast.clear();
try {
await loadInvitationHistory();
} catch (e) {
toast.showError(formatApiMessage(e));
} finally {
setHistoryLoading(false);
}
}
async function handleHistoryCopy(invitation: OrganizationInvitationHistoryItemDto) {
toast.setError('');
try {
await copyInvitationLink(invitation, {
onRegenerated: async () => {
await loadInvitationHistory();
},
});
toast.showSuccess(t('successLinkCopied'));
} catch (e) {
toast.showError(formatApiMessage(e));
}
}
async function handleCopyInvitationFromRow(row: CounterpartItemDto) {
const target = invitationTargetFromConnectionRow(row, currentOrganization!.id);
if (!target) return;
toast.setError('');
try {
await copyInvitationLink(
{
id: target.id,
organizationName: row.organizationName,
ownerEmail: target.ownerEmail,
status: target.status,
createdAt: row.createdAt,
acceptedAt: target.acceptedAt,
},
{
onRegenerated: async () => {
await loadList();
},
},
);
toast.showSuccess(t('successLinkCopied'));
} catch (e) {
toast.showError(formatApiMessage(e));
}
}
async function respondToPendingConnection(connectionId: string, action: 'ACCEPT' | 'REJECT') {
setPendingConnectionRowId(connectionId);
toast.setError('');
try {
await organizationApi.respondToConnectionRequest(connectionId, action);
toast.showSuccess(
action === 'ACCEPT' ? t('successAccepted') : t('successDeclined'),
);
notifyPendingConnectionsChanged();
await loadList();
} catch (e) {
toast.showError(formatApiMessage(e));
} finally {
setPendingConnectionRowId(null);
}
}
async function deleteConnection(connectionId: string) {
setDeleteConnectionRowId(connectionId);
toast.setError('');
try {
await organizationApi.deleteConnection(connectionId);
toast.showSuccess(t('successRemoved'));
await loadList();
} catch (e) {
toast.showError(formatApiMessage(e));
} finally {
setDeleteConnectionRowId(null);
}
}
function clearSearchView() {
setMode('existing');
setQuery('');
setSearchResults([]);
setShowInviteForm(false);
}
if (!currentOrganization) {
return <p className="text-sm text-text-secondary">{t('loadingOrganization')}</p>;
}
return (
<div className="space-y-6">
<div className="flex flex-col gap-1 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-2xl font-semibold text-text-primary">{tabLabel}</h1>
<p className="text-sm text-text-secondary mt-1">{t('subtitle')}</p>
</div>
<Button type="button" size="sm" onClick={() => void openInvitationHistory()}>
{t('invitationHistory')}
</Button>
</div>
{!historyOpen && <ToastStack {...toast.messages} />}
<SearchBar
value={query}
onChange={setQuery}
onSubmit={() => void runSearch()}
placeholder={t('searchPlaceholder', { counterpart: counterpart.toLowerCase() })}
actions={
<>
<button
type="button"
onClick={() => void runSearch()}
disabled={searching}
className="px-4 py-2 rounded-[var(--radius-sm)] text-sm font-medium border bg-primary-soft text-primary border-primary/50 disabled:opacity-60"
>
{tCommon('search')}
</button>
{mode === 'search' && (
<button
type="button"
onClick={clearSearchView}
className="px-4 py-2 rounded-[var(--radius-sm)] text-sm font-medium border text-text-secondary border-border/40 hover:bg-background-card/70 hover:border-border"
>
{t('backToList')}
</button>
)}
</>
}
/>
<Table
headers={
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
{t('tableOrganization')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
{t('tableOwnerEmail')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
{t('tableDate')}
</th>
<th className="px-6 py-3 text-center text-xs font-medium text-text-muted uppercase tracking-wider">
{t('tableStatus')}
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-text-muted uppercase tracking-wider">
{t('tableAction')}
</th>
</tr>
}
body={
<>
{loading ? (
<tr>
<td colSpan={5} className="px-6 py-8 text-sm text-text-secondary">
{tCommon('loadingEllipsis')}
</td>
</tr>
) : mode === 'existing' ? (
existingRows.length === 0 ? (
<tr>
<td colSpan={5} className="px-6 py-8 text-sm text-text-secondary">
{t('emptyConnections')}
</td>
</tr>
) : (
existingRows.map((row) => {
const canRespond =
row.status === 'PENDING' &&
row.requestedByOrganizationId !== null &&
row.requestedByOrganizationId !== currentOrganization.id;
const invitationTarget = invitationTargetFromConnectionRow(
row,
currentOrganization.id,
);
return (
<tr key={row.id} className="hover:bg-background-secondary/45">
<td className="px-6 py-1.5 text-sm font-medium text-text-primary">
{row.organizationName}
</td>
<td className="px-6 py-1.5 text-sm text-text-secondary">{row.ownerEmail}</td>
<td className="px-6 py-1.5 text-sm text-text-secondary">
{formatTableDate(row.createdAt)}
</td>
<td className="px-6 py-1.5 text-center align-middle">
<Badge variant={organizationConnectionStatusVariant(row.status)} fixedWidth={false}>
{formatConnectionStatusLabel(row, currentOrganization.id)}
</Badge>
</td>
<td className="px-6 py-1.5 text-right">
<div className="inline-flex items-center gap-2">
{invitationTarget && (
<CopyInvitationLinkButton
invitation={invitationTarget}
copied={copiedId === invitationTarget.id}
copying={copyingInvitationId === invitationTarget.id}
onCopy={() => void handleCopyInvitationFromRow(row)}
/>
)}
{canRespond && (
<>
<button
type="button"
className="p-2 rounded-md text-text-secondary hover:bg-background-card/80 hover:text-text-primary disabled:text-text-muted disabled:opacity-50"
disabled={pendingConnectionRowId !== null && pendingConnectionRowId !== row.id}
onClick={() => void respondToPendingConnection(row.id, 'ACCEPT')}
aria-label={t('acceptRequest')}
title={t('acceptRequest')}
>
<Check className="w-4 h-4" />
</button>
<button
type="button"
className="p-2 rounded-md text-text-secondary hover:bg-red-500/15 hover:text-red-600 disabled:text-text-muted disabled:opacity-50"
disabled={pendingConnectionRowId !== null && pendingConnectionRowId !== row.id}
onClick={() => void respondToPendingConnection(row.id, 'REJECT')}
aria-label={t('declineRequest')}
title={t('declineRequest')}
>
<X className="w-4 h-4" />
</button>
</>
)}
{row.status === 'ACTIVE' && (
<button
type="button"
className="p-2 rounded-md text-text-secondary hover:bg-red-500/15 hover:text-red-600 disabled:text-text-muted disabled:opacity-50"
disabled={deleteConnectionRowId !== null && deleteConnectionRowId !== row.id}
onClick={() => void deleteConnection(row.id)}
aria-label={t('removeConnection')}
title={t('removeConnection')}
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
</td>
</tr>
);
})
)
) : searchResults.length > 0 ? (
searchResults.map((r) => (
<tr key={r.id} className="hover:bg-background-secondary/45">
<td className="px-6 py-1.5 text-sm font-medium text-text-primary">{r.name}</td>
<td className="px-6 py-1.5 text-sm text-text-secondary">{r.owner.email}</td>
<td className="px-6 py-1.5 text-sm text-text-secondary">{t('statusToday')}</td>
<td className="px-6 py-1.5 text-center align-middle">
<Badge variant="default" fixedWidth={false}>{t('statusFound')}</Badge>
</td>
<td className="px-6 py-1.5 text-right">
<button
type="button"
className="p-2 rounded-md text-text-secondary hover:bg-background-card/80 hover:text-text-primary disabled:text-text-muted disabled:opacity-50"
disabled={
pendingConnectionRowId !== null && pendingConnectionRowId !== r.id
}
onClick={() => void submitConnectionRequest(r.id)}
aria-label={t('sendRequest')}
title={t('sendRequest')}
>
<UserPlus className="w-4 h-4" />
</button>
</td>
</tr>
))
) : (
<tr>
<td colSpan={5} className="px-6 py-6">
<div className="flex flex-col gap-3">
<p className="text-sm text-text-secondary">
{t('noDirectoryResults')}
</p>
<div className="flex flex-wrap items-center gap-2">
<Button type="button" onClick={() => setShowInviteForm((v) => !v)}>
{showInviteForm ? t('hideInvitationFields') : t('sendInvitationLink')}
</Button>
</div>
{showInviteForm && (
<div className="grid gap-3 sm:grid-cols-3 mt-1">
<Input
label={t('counterpartNameLabel', { counterpart })}
value={manualOrganizationName}
onChange={(e) => setManualOrganizationName(e.target.value)}
/>
<Input
label={t('ownerEmailLabel')}
type="email"
value={manualOwnerEmail}
onChange={(e) => setManualOwnerEmail(e.target.value)}
/>
<div className="flex items-end">
<Button
type="button"
isLoading={inviteLoading}
disabled={!manualOrganizationName.trim() || !manualOwnerEmail.trim()}
onClick={() => void sendInvite()}
className="w-full"
>
{t('sendInvitation')}
</Button>
</div>
</div>
)}
</div>
</td>
</tr>
)}
</>
}
/>
<InvitationHistoryDialog
open={historyOpen}
onClose={() => setHistoryOpen(false)}
loading={historyLoading}
items={historyItems}
copiedId={copiedId}
copyingInvitationId={copyingInvitationId}
onCopy={(invitation) => void handleHistoryCopy(invitation)}
toastMessages={toast.messages}
/>
</div>
);
}

View File

@@ -0,0 +1,151 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/shared/Button';
import { ToastStack } from '@/components/ui/shared/Toast';
import { patientsApi } from '@/lib/api/patients';
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 { CreatePatientInput, Patient } from '@/types/patient';
import { PatientSearchSelect } from '@/components/ui/patient/PatientSearchSelect';
import { CreatePatientModal } from '@/components/ui/patient/CreatePatientModal';
import { PatientSummaryCard } from '@/components/ui/patient/PatientSummaryCard';
const EMPTY_PATIENT_FORM: CreatePatientInput = {
firstName: '',
lastName: '',
phone: '',
email: '',
};
export default function PatientsPage() {
const t = useTranslations('patients');
const tCommon = useTranslations('common');
const { currentOrganization } = useAuth();
const toast = useToast();
const [search, setSearch] = useState('');
const [patients, setPatients] = useState<Patient[]>([]);
const [selectedPatient, setSelectedPatient] = useState<Patient | undefined>();
const [loadingPatients, setLoadingPatients] = useState(false);
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [savingPatient, setSavingPatient] = useState(false);
const [patientForm, setPatientForm] = useState<CreatePatientInput>(EMPTY_PATIENT_FORM);
const canEditPatients = hasPermission(currentOrganization, 'TAB_PATIENTS_EDIT');
const sortedPatients = useMemo(
() =>
[...patients].sort((a, b) =>
`${a.firstName} ${a.lastName}`.localeCompare(`${b.firstName} ${b.lastName}`),
),
[patients],
);
useEffect(() => {
const timeout = setTimeout(() => {
void loadPatients(search);
}, 300);
return () => clearTimeout(timeout);
}, [search]);
useEffect(() => {
void loadPatients('');
}, []);
async function loadPatients(q: string) {
setLoadingPatients(true);
toast.setError('');
try {
const response = await patientsApi.list({ q, page: 1, limit: 25 });
const items = response.data.items;
setPatients(items);
if (selectedPatient) {
const freshSelected = items.find((item) => item.id === selectedPatient.id);
setSelectedPatient(freshSelected);
}
} catch (error: unknown) {
toast.showError(formatApiErrorMessage(error, t('errorLoadPatients')));
} finally {
setLoadingPatients(false);
}
}
async function handleCreatePatient() {

View File

@@ -0,0 +1,10 @@
export default function ReportsPage() {
return (
<div className="space-y-3">
<h1 className="text-2xl font-semibold text-text-primary">Reports</h1>
<p className="text-sm text-text-secondary">
Reports module is coming soon.
</p>
</div>
);
}

View File

@@ -0,0 +1,28 @@
'use client';
import { useTranslations } from 'next-intl';
import { Link } from '@/i18n/navigation';
export default function AccountSettingsPage() {
const t = useTranslations('settings');
const tCommon = useTranslations('common');
return (
<div className="space-y-6">
<div>
<Link
href="/today"
className="text-sm text-primary hover:opacity-90"
>
{tCommon('backToApp')}
</Link>
<h1 className="text-2xl font-semibold text-text-primary mt-4">{t('accountTitle')}</h1>
<p className="text-text-secondary text-sm mt-2">{t('accountSubtitle')}</p>
</div>
<div className="surface-card p-6 space-y-3">
<p className="text-sm text-text-secondary">{t('accountPlaceholder')}</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,23 @@
'use client';
import { useTranslations } from 'next-intl';
import { Link } from '@/i18n/navigation';
import { OrganizationSelectorContent } from '@/components/ui/organizations/OrganizationSelectorContent';
export default function DashboardOrganizationsSettingsPage() {
const tCommon = useTranslations('common');
return (
<div className="space-y-6">
<div>
<Link
href="/today"
className="text-sm text-primary hover:opacity-90"
>
{tCommon('backToApp')}
</Link>
</div>
<OrganizationSelectorContent />
</div>
);
}

View File

@@ -0,0 +1,204 @@
'use client';
import { useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { Link, useRouter } from '@/i18n/navigation';
import { useAuth } from '@/lib/hooks/useAuth';
import { authApi } from '@/lib/api/auth';
import { Button } from '@/components/ui/shared/Button';
import { Toast } from '@/components/ui/shared/Toast';
import type { SubscriptionAlertData } from '@/types/subscription';
const PLAN_OPTIONS = [
{ id: 'solo', nameKey: 'planSolo' as const, maxUsers: 1, price: 19 },
{ id: 'small', nameKey: 'planSmall' as const, maxUsers: 5, price: 49 },
{ id: 'medium', nameKey: 'planMedium' as const, maxUsers: 10, price: 89 },
{ id: 'large', nameKey: 'planLarge' as const, maxUsers: 15, price: 129 },
{ id: 'enterprise', nameKey: 'planEnterprise' as const, maxUsers: null, price: 199 },
] as const;
export default function SubscriptionsSettingsPage() {
const t = useTranslations('settings');
const tCommon = useTranslations('common');
const { currentOrganization } = useAuth();
const router = useRouter();
const [alert, setAlert] = useState<SubscriptionAlertData | null>(null);
const [selectedPlanId, setSelectedPlanId] = useState<string>(PLAN_OPTIONS[0].id);
const [purchaseNotice, setPurchaseNotice] = useState<string | null>(null);
useEffect(() => {
if (currentOrganization && !currentOrganization.isOwner) {
router.replace('/today');
}
}, [currentOrganization, router]);
useEffect(() => {
if (!currentOrganization?.isOwner) return;
void authApi.getSubscriptionAlert().then((r) => {
if (r.success) setAlert(r.data);
});
}, [currentOrganization?.id, currentOrganization?.isOwner]);
if (!currentOrganization) {
return (
<p className="text-text-secondary text-sm">{tCommon('loadingEllipsis')}</p>
);
}
if (!currentOrganization.isOwner) {
return (
<p className="text-text-secondary text-sm">{tCommon('redirecting')}</p>
);
}
const plan = currentOrganization.plan;
const hasActiveSubscription = Boolean(plan);
const selectedPlan = PLAN_OPTIONS.find((option) => option.id === selectedPlanId);
const maxUsers = plan?.maxUsers;
const isUnlimited = typeof maxUsers === 'number' && maxUsers >= 999999;
const seatsUsed = alert?.seatsUsed;
const seatsRemaining =
typeof seatsUsed === 'number' && typeof maxUsers === 'number' && !isUnlimited
? Math.max(0, maxUsers - seatsUsed)
: null;
const daysUntilPlanEnd = alert?.daysUntilPlanEnd ?? null;
const planDayTone =
daysUntilPlanEnd == null
? 'text-text-primary'
: daysUntilPlanEnd > 20
? 'text-emerald-400'
: daysUntilPlanEnd >= 10
? 'text-amber-300'
: 'text-red-400';
return (
<div className="relative space-y-6 pb-24">
<div>
<Link
href="/today"
className="text-sm text-primary hover:opacity-90"
>
{tCommon('backToApp')}
</Link>
<h1 className="text-2xl font-semibold text-text-primary mt-4">{t('subscriptionsTitle')}</h1>
<p className="text-text-secondary text-sm mt-2">
{t('subscriptionsSubtitle', { orgName: currentOrganization.name })}
</p>
</div>
<div className="surface-card p-6 space-y-4">
{!hasActiveSubscription && (
<div className="rounded-[var(--radius-md)] border border-amber-500/30 bg-amber-500/10 p-4">
<p className="text-sm text-amber-200">{t('noSubscriptionNotice')}</p>
</div>
)}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
<div>
<p className="text-xs text-text-muted uppercase tracking-wide">{t('currentPlan')}</p>
<p className="text-lg font-medium text-text-primary capitalize">
{plan?.name ?? '—'}
</p>
</div>
<div>
<p className="text-xs text-text-muted uppercase tracking-wide">{t('planPrice')}</p>
<p className="text-lg font-medium text-text-primary">
{typeof plan?.price === 'number' ? `$${plan.price}` : '—'}
</p>
</div>
<div>
<p className="text-xs text-text-muted uppercase tracking-wide">{t('seatsUsed')}</p>
<p className="text-lg font-medium text-text-primary">
{typeof seatsUsed === 'number' ? seatsUsed : '—'}
{typeof maxUsers === 'number'
? ` / ${isUnlimited ? t('unlimited') : maxUsers}`
: ''}
</p>
</div>
<div>
<p className="text-xs text-text-muted uppercase tracking-wide">{t('seatsRemaining')}</p>
<p className="text-lg font-medium text-text-primary">
{isUnlimited ? t('unlimited') : seatsRemaining ?? '—'}
</p>
</div>
<div>
<p className="text-xs text-text-muted uppercase tracking-wide">{t('daysRemaining')}</p>
<p className={`text-lg font-medium ${planDayTone}`}>
{daysUntilPlanEnd ?? '—'}
</p>
</div>
</div>
{alert?.showWarning && (
<div className="text-sm text-text-secondary space-y-1">
{alert.noActiveSubscription && (
<p>{t('noActiveSubscription')}</p>
)}
{alert.trialExpired && (
<p>{t('trialEnded')}</p>
)}
{!alert.trialExpired && alert.trialEndingSoon && (
<p>
{t('trialEndsIn', { days: alert.daysUntilTrialEnd ?? '—' })}
</p>
)}
{!alert.trialExpired && !alert.trialEndingSoon && alert.seatsLow && (
<p>{t('seatsLow')}</p>
)}
</div>
)}
<div className="space-y-3 pt-2">
<p className="text-sm text-text-secondary">{t('choosePlanIntro')}</p>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{PLAN_OPTIONS.map((option) => {
const selected = selectedPlanId === option.id;
return (
<button
key={option.id}
type="button"
onClick={() => setSelectedPlanId(option.id)}
className={`rounded-[var(--radius-md)] border p-4 text-left transition-colors ${
selected
? 'border-primary/70 bg-primary-soft'
: 'border-border hover:border-border-strong'
}`}
>
<p className="text-base font-medium text-text-primary">{t(option.nameKey)}</p>
<p className="text-sm text-text-secondary mt-1">
{option.maxUsers == null
? t('unlimitedSeats')
: t('seatsCount', { n: option.maxUsers })}
</p>
<p className="text-sm text-text-secondary mt-1">
{t('pricePerMonth', { price: option.price })}
</p>
</button>
);
})}
</div>
<Button
type="button"
variant="primary"
onClick={() => {
const selectedPlanLabel = selectedPlan
? t(selectedPlan.nameKey)
: t('planSolo');
setPurchaseNotice(t('purchaseNotice', { plan: selectedPlanLabel }));
}}
>
{t('startPurchase')}
</Button>
</div>
</div>
{purchaseNotice && (
<div className="fixed bottom-4 left-0 right-0 z-[70] px-4 pointer-events-none">
<div className="pointer-events-auto w-full">
<Toast variant="success">{purchaseNotice}</Toast>
</div>
</div>
)}
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,55 @@
'use client';
import { useTranslations } from 'next-intl';
import { Link } from '@/i18n/navigation';
import { useAuth } from '@/lib/hooks/useAuth';
import { Card } from '@/components/ui/shared/Card';
export default function TodayPage() {
const t = useTranslations('today');
const { currentOrganization } = useAuth();
const showNoSubscriptionNotice =
Boolean(currentOrganization?.isOwner) && !currentOrganization?.plan;
return (
<div>
<h1 className="text-2xl font-semibold mb-6">
{t('welcomeBack')}
</h1>
{showNoSubscriptionNotice && (
<div className="mb-6 rounded-[var(--radius-md)] border border-amber-500/30 bg-amber-500/10 p-4">
<p className="text-sm text-amber-200">
{t('noSubscriptionNotice')}{' '}
<Link href="/settings/subscriptions" className="font-medium underline underline-offset-2">
{t('choosePlanLink')}
</Link>{' '}
{t('noSubscriptionCta')}
</p>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
<Card>
<p className="text-sm text-card-muted">{t('cardTodaysAppointments')}</p>
<p className="text-2xl font-semibold mt-2">12</p>
<p className="text-xs text-text-muted mt-1">Monday 2/5/2026</p>
</Card>
<Card>
<p className="text-sm text-card-muted">{t('cardActivePatients')}</p>
<p className="text-2xl font-semibold mt-2">675</p>
</Card>
<Card>
<p className="text-sm text-card-muted">{t('cardNewLabCase')}</p>
<p className="text-2xl font-semibold mt-2">5</p>
<p className="text-xs text-text-muted mt-1">35 </p>
</Card>
<Card>
<p className="text-sm text-card-muted">{t('cardTodayInvoices')}</p>
<p className="text-2xl font-semibold mt-2">1200$</p>
<p className="text-xs text-text-muted mt-1">21,300 $</p>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,20 @@
'use client';
import { useTranslations } from 'next-intl';
import { TreatmentWorkspace } from '@/components/ui/treatment/TreatmentWorkspace';
import { useAuth } from '@/lib/hooks/useAuth';
export default function TreatmentPage() {
const t = useTranslations('treatment');
const { user, currentOrganization, isAuthReady } = useAuth();
if (!isAuthReady || !user) {
return (
<div className="text-sm text-text-muted">{t('loading')}</div>
);
}
return (
<TreatmentWorkspace userId={user.id} currentOrganization={currentOrganization} />
);
}