feature: localization's first implmentation

This commit is contained in:
2026-06-20 12:37:38 +03:30
parent b314a5fa11
commit 284fbd08aa
46 changed files with 1860 additions and 643 deletions

View File

@@ -0,0 +1,349 @@
'use client';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
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 { 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, 'Failed to load schedule.'));
} finally {
if (gen === scheduleLoadGen.current) {
setLoadingSchedule(false);
}
}
}, [currentOrganization?.id, scheduleDate]);
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(`Patient ${response.data.firstName} ${response.data.lastName} was saved.`);
} catch (err: unknown) {
const message =
err && typeof err === 'object' && 'message' in err
? String((err as { message: unknown }).message)
: 'Failed to save patient.';
toast.showError(message);
} finally {
setSavingPatient(false);
}
}
function handleSlotClick(startMinute: number, providerUserId: string, providerName: string) {
if (isViewingPastDay) {
toast.showInfo('Past appointments are view-only.');
return;
}
if (!selectedPatient) {
toast.showInfo('Select a patient before booking.');
return;
}
setBookingStartMinute(startMinute);
setBookingProviderId(providerUserId);
setBookingProviderName(providerName);
setEditingAppointmentId(null);
setBookingOpen(true);
}
function handleAppointmentClick(appointment: AppointmentRecord) {
if (isViewingPastDay) {
toast.showInfo('Past appointments are view-only.');
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(
'This appointment falls outside the providers current working hours and cannot be edited.',
);
}
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 ? 'Appointment updated.' : 'Appointment saved.');
await loadSchedule();
} catch (err: unknown) {
const message =
err && typeof err === 'object' && 'message' in err
? String((err as { message: unknown }).message)
: activeEditingAppointment
? 'Could not update appointment.'
: 'Could not save appointment.';
toast.showError(message);
} finally {
setSavingAppointment(false);
}
}
async function handleDeleteEditingAppointment() {
if (!activeEditingAppointment) {
return;
}
if (!window.confirm('Remove this appointment?')) {
return;
}
setDeletingAppointment(true);
toast.setError('');
try {
await appointmentsApi.remove(activeEditingAppointment.id);
setBookingOpen(false);
setEditingAppointmentId(null);
toast.showSuccess('Appointment removed.');
await loadSchedule();
} catch (err: unknown) {
const message =
err && typeof err === 'object' && 'message' in err
? String((err as { message: unknown }).message)
: 'Could not delete appointment.';
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">Appointments</h1>
<p className="text-sm text-text-secondary">
Search a patient, pick a date, then click a time slot under a provider to book.
</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">Loading schedule</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,522 @@
'use client';
import { useEffect, useState } from 'react';
import { useToast } from '@/lib/hooks/useToast';
import { Check, Trash2, UserPlus, X } from 'lucide-react';
import { useAuth } from '@/lib/hooks/useAuth';
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 formatConnectionStatusLabel(
row: CounterpartItemDto,
currentOrganizationId: string,
): string {
if (row.status === 'PENDING') {
if (
row.pendingInvitationId &&
row.requestedByOrganizationId === currentOrganizationId
) {
return 'Invitation pending';
}
return 'Connection request pending';
}
if (row.status === 'ACTIVE') return 'Connected';
if (row.status === 'REJECTED') return 'Connection request declined';
return formatOrganizationStatusLabel(row.status);
}
function formatApiMessage(err: unknown): string {
if (!err || typeof err !== 'object') return 'Something went wrong';
const m = (err as ApiError).message;
if (Array.isArray(m)) return m.join(', ');
if (typeof m === 'string') return m;
return 'Something went wrong';
}
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 { currentOrganization } = useAuth();
const [loading, setLoading] = useState(true);
const toast = useToast();
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 counterpartLabel = currentOrganization?.type === 'LAB' ? 'Clinic' : 'Lab';
const tabLabel = currentOrganization?.type === 'LAB' ? 'Clinics' : '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(`${counterpartLabel} connection request sent.`);
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(`Invitation link created for ${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('Invitation link copied to clipboard.');
} 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('Invitation link copied to clipboard.');
} 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' ? 'Connection request accepted.' : 'Connection request declined.',
);
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('Connection removed.');
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">Loading organization...</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">
Search organizations, send connection requests to existing accounts, or invitation
links when they are not on DyoLink yet.
</p>
</div>
<Button type="button" size="sm" onClick={() => void openInvitationHistory()}>
Invitation History
</Button>
</div>
{!historyOpen && <ToastStack {...toast.messages} />}
<SearchBar
value={query}
onChange={setQuery}
onSubmit={() => void runSearch()}
placeholder={`Search ${counterpartLabel.toLowerCase()} by name, email, or phone...`}
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"
>
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"
>
Back to list
</button>
)}
</>
}
/>
<Table
headers={
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
Organization
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
Owner email
</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-center text-xs font-medium text-text-muted uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-text-muted uppercase tracking-wider">
Action
</th>
</tr>
}
body={
<>
{loading ? (
<tr>
<td colSpan={5} className="px-6 py-8 text-sm text-text-secondary">
Loading...
</td>
</tr>
) : mode === 'existing' ? (
existingRows.length === 0 ? (
<tr>
<td colSpan={5} className="px-6 py-8 text-sm text-text-secondary">
No connections yet. Search to send a connection request or an invitation link.
</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="Accept connection request"
title="Accept connection request"
>
<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="Decline connection request"
title="Decline connection request"
>
<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="Remove connection"
title="Remove connection"
>
<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">Today</td>
<td className="px-6 py-1.5 text-center align-middle">
<Badge variant="default" fixedWidth={false}>Found</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="Send connection request"
title="Send connection request"
>
<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">
No organization found in directory search.
</p>
<div className="flex flex-wrap items-center gap-2">
<Button type="button" onClick={() => setShowInviteForm((v) => !v)}>
{showInviteForm ? 'Hide invitation fields' : 'Send invitation link'}
</Button>
</div>
{showInviteForm && (
<div className="grid gap-3 sm:grid-cols-3 mt-1">
<Input
label={`${counterpartLabel} name`}
value={manualOrganizationName}
onChange={(e) => setManualOrganizationName(e.target.value)}
/>
<Input
label="Owner email"
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"
>
Send invitation
</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,145 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
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 { 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, 'Failed to load patients.'));
} finally {
setLoadingPatients(false);
}
}
async function handleCreatePatient() {
setSavingPatient(true);
toast.setError('');
try {
const response = await patientsApi.create(patientForm);
setIsCreateOpen(false);
setPatientForm(EMPTY_PATIENT_FORM);
await loadPatients(search);
setSelectedPatient(response.data);
toast.showSuccess(
`Patient ${response.data.firstName} ${response.data.lastName} was saved successfully.`,
);
} catch (error: unknown) {
toast.showError(formatApiErrorMessage(error, 'Failed to save patient.'));
} finally {
setSavingPatient(false);
}
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between gap-3">
<h1 className="text-2xl font-semibold text-text-primary">Patients</h1>
<Button
variant="primary"
disabled={!canEditPatients}
onClick={() => {
if (!canEditPatients) return;
toast.clear();
setPatientForm(EMPTY_PATIENT_FORM);
setIsCreateOpen(true);
}}
title={!canEditPatients ? 'Read-only access for this organization.' : undefined}
>
New Patient
</Button>
</div>
<ToastStack {...toast.messages} />
{isCreateOpen && (
<CreatePatientModal
isOpen={isCreateOpen}
formData={patientForm}
onChange={(patch) => setPatientForm((prev) => ({ ...prev, ...patch }))}
onSubmit={() => void handleCreatePatient()}
onClose={() => {
setIsCreateOpen(false);
setPatientForm(EMPTY_PATIENT_FORM);
}}
loading={savingPatient}
/>
)}
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
<div className="xl:col-span-1">
<PatientSearchSelect
search={search}
onSearchChange={setSearch}
patients={sortedPatients}
selectedPatientId={selectedPatient?.id}
onSelectPatient={setSelectedPatient}
loading={loadingPatients}
/>
</div>
<div className="xl:col-span-2 space-y-4">
<PatientSummaryCard patient={selectedPatient} />
</div>
</div>
</div>
);
}

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,29 @@
'use client';
import { Link } from '@/i18n/navigation';
export default function AccountSettingsPage() {
return (
<div className="space-y-6">
<div>
<Link
href="/today"
className="text-sm text-primary hover:opacity-90"
>
Back to app
</Link>
<h1 className="text-2xl font-semibold text-text-primary mt-4">Account</h1>
<p className="text-text-secondary text-sm mt-2">
Profile and security settings for your login.
</p>
</div>
<div className="surface-card p-6 space-y-3">
<p className="text-sm text-text-secondary">
Password change and profile editing will be wired here next (e.g. invite
flow, reset password).
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,20 @@
'use client';
import { Link } from '@/i18n/navigation';
import { OrganizationSelectorContent } from '@/components/ui/organizations/OrganizationSelectorContent';
export default function DashboardOrganizationsSettingsPage() {
return (
<div className="space-y-6">
<div>
<Link
href="/today"
className="text-sm text-primary hover:opacity-90"
>
Back to app
</Link>
</div>
<OrganizationSelectorContent />
</div>
);
}

View File

@@ -0,0 +1,204 @@
'use client';
import { useEffect, useState } from 'react';
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', name: 'Solo', maxUsers: 1, price: 19 },
{ id: 'small', name: 'Small', maxUsers: 5, price: 49 },
{ id: 'medium', name: 'Medium', maxUsers: 10, price: 89 },
{ id: 'large', name: 'Large', maxUsers: 15, price: 129 },
{ id: 'enterprise', name: 'Enterprise', maxUsers: null, price: 199 },
] as const;
export default function SubscriptionsSettingsPage() {
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">Loading...</p>
);
}
if (!currentOrganization.isOwner) {
return (
<p className="text-text-secondary text-sm">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"
>
Back to app
</Link>
<h1 className="text-2xl font-semibold text-text-primary mt-4">Subscriptions</h1>
<p className="text-text-secondary text-sm mt-2">
Your DyoLink workspace plan and seats for{' '}
<span className="text-text-primary font-medium">{currentOrganization.name}</span>.
Clinic and lab income tracking stays under the sidebar{' '}
<span className="text-text-primary">Billing</span> tab.
</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">
This organization has no active subscription. Select a plan below to start
the purchase process.
</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">Current plan</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">Plan price</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">Seats used</p>
<p className="text-lg font-medium text-text-primary">
{typeof seatsUsed === 'number' ? seatsUsed : '—'}
{typeof maxUsers === 'number' ? ` / ${isUnlimited ? 'Unlimited' : maxUsers}` : ''}
</p>
</div>
<div>
<p className="text-xs text-text-muted uppercase tracking-wide">Seats remaining</p>
<p className="text-lg font-medium text-text-primary">
{isUnlimited ? 'Unlimited' : seatsRemaining ?? '—'}
</p>
</div>
<div>
<p className="text-xs text-text-muted uppercase tracking-wide">Days remaining</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>No active subscription for this organization.</p>
)}
{alert.trialExpired && (
<p>Trial period has ended. Choose a plan when checkout is available.</p>
)}
{!alert.trialExpired && alert.trialEndingSoon && (
<p>
Trial ends in {alert.daysUntilTrialEnd ?? '—'} day(s).
</p>
)}
{!alert.trialExpired && !alert.trialEndingSoon && alert.seatsLow && (
<p>Seat usage is high for this organization.</p>
)}
</div>
)}
<div className="space-y-3 pt-2">
<p className="text-sm text-text-secondary">
Choose a plan to continue. Purchase integration is not active yet, so this
currently prepares the selection step only.
</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">{option.name}</p>
<p className="text-sm text-text-secondary mt-1">
{option.maxUsers == null ? 'Unlimited seats' : `${option.maxUsers} seats`}
</p>
<p className="text-sm text-text-secondary mt-1">${option.price} / month</p>
</button>
);
})}
</div>
<Button
type="button"
variant="primary"
onClick={() => {
const selectedPlanLabel = selectedPlan?.name ?? 'the selected plan';
setPurchaseNotice(
`Purchase flow will be enabled soon. ${selectedPlanLabel} is selected and ready for checkout setup.`,
);
}}
>
Start purchase process
</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,53 @@
'use client';
import { Link } from '@/i18n/navigation';
import { useAuth } from '@/lib/hooks/useAuth';
import { Card } from '@/components/ui/shared/Card';
export default function TodayPage() {
const { currentOrganization } = useAuth();
const showNoSubscriptionNotice =
Boolean(currentOrganization?.isOwner) && !currentOrganization?.plan;
return (
<div>
<h1 className="text-2xl font-semibold mb-6">
Welcome back!!
</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">
This organization does not have an active subscription yet.{' '}
<Link href="/settings/subscriptions" className="font-medium underline underline-offset-2">
Choose a plan
</Link>{' '}
to start the purchase process.
</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">Today's Appointments</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">Active Patients</p>
<p className="text-2xl font-semibold mt-2">675</p>
</Card>
<Card>
<p className="text-sm text-card-muted">New Lab Case</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">Today invoices</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,18 @@
'use client';
import { TreatmentWorkspace } from '@/components/ui/treatment/TreatmentWorkspace';
import { useAuth } from '@/lib/hooks/useAuth';
export default function TreatmentPage() {
const { user, currentOrganization, isAuthReady } = useAuth();
if (!isAuthReady || !user) {
return (
<div className="text-sm text-text-muted">Loading</div>
);
}
return (
<TreatmentWorkspace userId={user.id} currentOrganization={currentOrganization} />
);
}