improvement: some files replaced, lots of them i shall say. AGENT.MD file created. some rules and skills added for cursor agent.

This commit is contained in:
2026-07-12 21:08:29 +03:30
parent 1cdf853d32
commit 0d3fb0a51d
86 changed files with 4758 additions and 4260 deletions

View File

@@ -1,373 +1,7 @@
'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 { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
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 { getUserFacingError } from '@/components/shared/formatApiError';
import { compareLocalDayStart, getLocalDayIsoRange, startOfLocalDay } from '@/components/appointments/appointmentTime';
import { AppointmentsPage } from '@/components/ui/appointments/AppointmentsPage';
const EMPTY_PATIENT_FORM: CreatePatientInput = {
firstName: '',
lastName: '',
mobile: '',
email: '',
};
export default function AppointmentsPage() {
const t = useTranslations('appointments');
const tErrors = useTranslations('errors');
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 [treatmentCatalog, setTreatmentCatalog] = useState<TreatmentCatalogEntry[]>([]);
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(getUserFacingError(err, tErrors, t('errorLoadSchedule')));
} finally {
if (gen === scheduleLoadGen.current) {
setLoadingSchedule(false);
}
}
}, [currentOrganization?.id, scheduleDate, t]);
useEffect(() => {
void loadSchedule();
}, [loadSchedule]);
useEffect(() => {
void treatmentCatalogApi
.list('appointment')
.then((r) => setTreatmentCatalog(r.data))
.catch(() => {});
}, []);
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);
if (response.existing) {
toast.showInfo(
tPatients('patientAlreadyExists', {
firstName: response.data.firstName,
lastName: response.data.lastName,
}),
);
} else {
toast.showSuccess(
t('successPatientSaved', {
firstName: response.data.firstName,
lastName: response.data.lastName,
}),
);
}
} catch (err: unknown) {
toast.showError(getUserFacingError(err, tErrors, tPatients('errorSavePatient')));
} finally {
setSavingPatient(false);
}
}
function handleSlotClick(startMinute: number, providerUserId: string, providerName: string) {
if (!canManageAppointments) {
return;
}
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 (!canManageAppointments) {
return;
}
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) {
toast.showError(
getUserFacingError(
err,
tErrors,
activeEditingAppointment ? t('errorUpdate') : t('errorSave'),
),
);
} 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) {
toast.showError(getUserFacingError(err, tErrors, t('errorDelete')));
} finally {
setDeletingAppointment(false);
}
}
return (
<div className="space-y-6">
<div className="flex flex-col gap-1">
<h1 className="text-xl sm: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 treatmentCatalog={treatmentCatalog} />
<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}
treatmentCatalog={treatmentCatalog}
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}
treatmentCatalog={treatmentCatalog}
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>
);
}
export default function Page() {
return <AppointmentsPage />;
}

View File

@@ -1,296 +1,7 @@
// src/app/(dashboard)/billing/page.tsx
'use client';
import { useMemo, 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';
import { BillingPage } from '@/components/ui/billing/BillingPage';
type InvoiceStatus = 'paid' | 'unpaid' | 'overdue';
type Invoice = {
id: string;
patient: string;
date: string;
service: string;
amount: number;
paid: number;
status: InvoiceStatus;
};
const invoices: Invoice[] = [
{ 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;
const statusFilters = ['all', 'paid', 'unpaid', 'overdue'] 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<(typeof statusFilters)[number]>('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 },
};
const filteredInvoices = useMemo(() => {
const query = search.trim().toLowerCase();
return invoices.filter((invoice) => {
const matchesStatus = statusFilter === 'all' || invoice.status === statusFilter;
const matchesSearch =
!query ||
invoice.patient.toLowerCase().includes(query) ||
invoice.id.toLowerCase().includes(query) ||
invoice.service.toLowerCase().includes(query);
return matchesStatus && matchesSearch;
});
}, [search, statusFilter]);
return (
<div className="space-y-4 sm:space-y-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h1 className="text-xl sm:text-2xl font-semibold text-text-primary">Billing</h1>
<Button
variant="primary"
disabled={!canEditBilling}
className="w-full sm:w-auto shrink-0"
title={!canEditBilling ? 'Read-only access for this organization.' : undefined}
>
New Invoice
</Button>
</div>
<div className="grid grid-cols-2 xl:grid-cols-4 gap-3 sm: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>
<SearchBar
value={search}
onChange={setSearch}
placeholder="Search patients..."
actions={(
<>
{statusFilters.map((status) => (
<button
key={status}
type="button"
onClick={() => setStatusFilter(status)}
className={`px-3 py-1.5 sm:px-4 sm:py-2 rounded-[var(--radius-sm)] text-xs 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>
))}
</>
)}
/>
<div className="lg:hidden space-y-3">
{filteredInvoices.length === 0 ? (
<div className="surface-card p-4 text-sm text-text-muted">No invoices match your filters.</div>
) : (
filteredInvoices.map((invoice) => (
<InvoiceMobileCard
key={invoice.id}
invoice={invoice}
canEditBilling={canEditBilling}
/>
))
)}
<InvoicePagination className="surface-card px-3 py-3 sm:px-6" />
</div>
<div className="hidden lg:block">
<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={
<>
{filteredInvoices.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]} className="capitalize">
{invoice.status}
</Badge>
</td>
<td className="px-6 py-1.5">
<InvoiceEditButton canEditBilling={canEditBilling} />
</td>
</tr>
))}
</>
}
footer={<InvoicePagination />}
/>
</div>
</div>
);
}
function InvoiceMobileCard({
invoice,
canEditBilling,
}: {
invoice: Invoice;
canEditBilling: boolean;
}) {
const remaining = invoice.amount - invoice.paid;
return (
<Card padding="sm" className="space-y-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-sm font-semibold text-text-primary truncate">{invoice.patient}</p>
<p className="text-xs text-text-muted mt-0.5">{invoice.id}</p>
</div>
<Badge variant={statusColors[invoice.status]} fixedWidth={false} className="capitalize shrink-0">
{invoice.status}
</Badge>
</div>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-text-secondary">
<span>{invoice.service}</span>
<span aria-hidden>·</span>
<span>{invoice.date}</span>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 text-sm">
<div>
<p className="text-xs text-text-muted">Total</p>
<p className="font-medium text-text-primary">${invoice.amount}</p>
</div>
<div>
<p className="text-xs text-text-muted">Paid</p>
<p className="font-medium text-text-primary">${invoice.paid}</p>
</div>
<div>
<p className="text-xs text-text-muted">Due</p>
<p className="font-medium text-text-primary">${remaining}</p>
</div>
</div>
<div className="flex justify-end pt-1 border-t border-border/60">
<InvoiceEditButton canEditBilling={canEditBilling} />
</div>
</Card>
);
}
function InvoiceEditButton({ canEditBilling }: { canEditBilling: boolean }) {
return (
<button
type="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>
);
}
function InvoicePagination({ className = '' }: { className?: string }) {
return (
<div
className={`flex flex-col gap-2 sm:flex-row sm:justify-between sm:items-center ${className}`.trim()}
>
<button type="button" className="text-sm text-text-secondary hover:text-text-primary">
Previous
</button>
<div className="text-sm text-text-secondary text-center">Page 1 of 10</div>
<button type="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={`min-w-0 ${colors[color]}`}>
<p className="text-xs sm:text-sm font-medium leading-snug">{title}</p>
<p className="text-xl sm:text-2xl font-bold mt-1 tabular-nums">{count}</p>
<p className="text-xs sm:text-sm font-medium mt-1 tabular-nums truncate">
${amount.toLocaleString()}
</p>
</Card>
);
}
export default function Page() {
return <BillingPage />;
}

View File

@@ -1,472 +1,7 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { ToastStack } from '@/components/ui/shared/Toast';
import { getUserFacingError } from '@/components/shared/formatApiError';
import { useAuth } from '@/lib/hooks/useAuth';
import { useToast } from '@/lib/hooks/useToast';
import { canEditCases, canEditTasks } from '@/components/shared/permissions';
import { CaseDetailPanel, CaseTaskProgressBar } from '@/components/ui/lab/CaseDetailPanel';
import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel';
import {
formatCaseDateTime,
formatPatientName,
} from '@/components/ui/lab/caseDetailUtils';
import { casesApi } from '@/lib/api/cases';
import { tasksApi } from '@/lib/api/tasks';
import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
import { treatmentTypeLabelFromCatalog } from '@/components/ui/treatment/treatmentTypeDisplay';
import { Button } from '@/components/ui/shared/Button';
import { MobileDetailBackButton } from '@/components/ui/shared/MobileDetailBackButton';
import { FORM_SELECT_CLASS } from '@/components/ui/shared/formSelectStyles';
import { SearchBar } from '@/components/ui/shared/SearchBar';
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
import type {
CasesFilterOptions,
LabCaseDetail,
LabCaseListItem,
LabTaskStatus,
PaginatedLabCases,
} from '@/types/cases';
import { CasesPage } from '@/components/ui/lab/CasesPage';
const PAGE_SIZE = 20;
export default function CasesPage() {
const t = useTranslations('cases');
const tErrors = useTranslations('errors');
const tCommon = useTranslations('common');
const { currentOrganization, user } = useAuth();
const toast = useToast();
const searchParams = useSearchParams();
const [search, setSearch] = useState('');
const [clinicId, setClinicId] = useState('');
const [treatmentType, setTreatmentType] = useState('');
const [sentFrom, setSentFrom] = useState('');
const [sentTo, setSentTo] = useState('');
const [page, setPage] = useState(1);
const [cases, setCases] = useState<LabCaseListItem[]>([]);
const [pagination, setPagination] = useState<PaginatedLabCases['pagination']>({
page: 1,
limit: PAGE_SIZE,
total: 0,
totalPages: 1,
});
const [filterOptions, setFilterOptions] = useState<CasesFilterOptions>({
clinics: [],
treatmentTypes: [],
});
const [treatmentCatalog, setTreatmentCatalog] = useState<TreatmentCatalogEntry[]>([]);
const [selectedCaseId, setSelectedCaseId] = useState<string | null>(null);
const [mobileDetailOpen, setMobileDetailOpen] = useState(false);
const [selectedCase, setSelectedCase] = useState<LabCaseDetail | null>(null);
const [loadingList, setLoadingList] = useState(false);
const [loadingDetail, setLoadingDetail] = useState(false);
const [updatingImportant, setUpdatingImportant] = useState(false);
const [commentCount, setCommentCount] = useState(0);
const canEdit = canEditCases(currentOrganization);
const canEditComments = canEditTasks(currentOrganization);
const locale = user?.language ?? 'en';
const treatmentLabel = useCallback(
(type: string) => treatmentTypeLabelFromCatalog(type, treatmentCatalog),
[treatmentCatalog],
);
const statusOptions: { value: LabTaskStatus; label: string }[] = useMemo(
() => [
{ value: 'IN_PROGRESS', label: t('statusInProgress') },
{ value: 'COMPLETED', label: t('statusCompleted') },
],
[t],
);
const hasActiveFilters = Boolean(
search.trim() || clinicId || treatmentType || sentFrom || sentTo,
);
const loadCases = async (params: {
q: string;
clinicOrganizationId: string;
treatmentType: string;
sentFrom: string;
sentTo: string;
page: number;
}) => {
setLoadingList(true);
toast.setError('');
try {
const response = await casesApi.list({
q: params.q.trim() || undefined,
clinicOrganizationId: params.clinicOrganizationId || undefined,
treatmentType: params.treatmentType || undefined,
sentFrom: params.sentFrom || undefined,
sentTo: params.sentTo || undefined,
page: params.page,
limit: PAGE_SIZE,
});
setCases(response.data.items);
setPagination(response.data.pagination);
} catch (error: unknown) {
toast.showError(getUserFacingError(error, tErrors, t('errorLoadList')));
} finally {
setLoadingList(false);
}
};
const loadDetail = async (caseId: string, options?: { silent?: boolean }) => {
if (!options?.silent) {
setLoadingDetail(true);
}
toast.setError('');
try {
const response = await casesApi.getOne(caseId);
setSelectedCase(response.data);
} catch (error: unknown) {
toast.showError(getUserFacingError(error, tErrors, t('errorLoadDetail')));
if (!options?.silent) {
setSelectedCase(null);
}
} finally {
if (!options?.silent) {
setLoadingDetail(false);
}
}
};
useEffect(() => {
void casesApi.listFilterOptions().then((r) => setFilterOptions(r.data)).catch(() => {});
void treatmentCatalogApi.list().then((r) => setTreatmentCatalog(r.data)).catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only initial fetch
}, []);
useEffect(() => {
const caseIdFromUrl = searchParams.get('caseId');
if (caseIdFromUrl) {
setSelectedCaseId(caseIdFromUrl);
setMobileDetailOpen(true);
}
}, [searchParams]);
useEffect(() => {
if (!selectedCaseId) {
setMobileDetailOpen(false);
}
}, [selectedCaseId]);
useEffect(() => {
const timeout = setTimeout(() => {
void loadCases({
q: search,
clinicOrganizationId: clinicId,
treatmentType,
sentFrom,
sentTo,
page,
});
}, search ? 300 : 0);
return () => clearTimeout(timeout);
// eslint-disable-next-line react-hooks/exhaustive-deps -- debounced search + filter reload
}, [search, clinicId, treatmentType, sentFrom, sentTo, page]);
useEffect(() => {
if (selectedCaseId) {
void loadDetail(selectedCaseId);
void tasksApi
.listComments(selectedCaseId)
.then((r) => setCommentCount(r.data.length))
.catch(() => setCommentCount(0));
} else {
setSelectedCase(null);
setCommentCount(0);
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- reload when selection changes
}, [selectedCaseId]);
function scrollToComments() {
document.getElementById('case-comments')?.scrollIntoView({ behavior: 'smooth' });
}
const loadCaseAttachmentBlob = useCallback(
(caseId: string, attachmentId: string) => casesApi.getAttachmentFileBlob(caseId, attachmentId),
[],
);
function clearFilters() {
setSearch('');
setClinicId('');
setTreatmentType('');
setSentFrom('');
setSentTo('');
setPage(1);
}
async function handleCaseImportantToggle(isImportant: boolean) {
if (!selectedCaseId || !canEdit || !selectedCase) return;
const previousCase = selectedCase;
setSelectedCase({ ...selectedCase, isImportant });
setUpdatingImportant(true);
toast.setError('');
try {
const response = await casesApi.setCaseImportant(selectedCaseId, isImportant);
setSelectedCase(response.data);
} catch (error: unknown) {
setSelectedCase(previousCase);
toast.showError(getUserFacingError(error, tErrors, t('errorUpdateTask')));
} finally {
setUpdatingImportant(false);
}
}
const filterSelectClass = `${FORM_SELECT_CLASS} w-full rounded-md px-3 py-2`;
return (
<div className="space-y-4">
<div>
<h1 className="text-xl sm:text-2xl font-semibold text-text-primary">{t('title')}</h1>
<p className="text-sm text-text-muted mt-1">{t('subtitle')}</p>
</div>
<div className="grid gap-4 lg:grid-cols-[minmax(300px,380px)_1fr]">
<section
className={`rounded-lg border border-border bg-surface p-3 sm:p-4 space-y-3 flex flex-col min-h-0 ${
mobileDetailOpen && selectedCaseId ? 'hidden lg:flex' : 'flex'
}`}
>
<SearchBar
embedded
value={search}
onChange={(value) => {
setSearch(value);
setPage(1);
}}
placeholder={t('searchPlaceholder')}
/>
<div className="grid gap-2 sm:grid-cols-2">
<label className="space-y-1">
<span className="text-xs font-medium text-text-muted">{t('filterClinic')}</span>
<select
value={clinicId}
onChange={(e) => {
setClinicId(e.target.value);
setPage(1);
}}
className={filterSelectClass}
>
<option value="">{t('filterClinicAll')}</option>
{filterOptions.clinics.map((clinic) => (
<option key={clinic.id} value={clinic.id}>
{clinic.name}
</option>
))}
</select>
</label>
<label className="space-y-1">
<span className="text-xs font-medium text-text-muted">{t('filterTreatmentType')}</span>
<select
value={treatmentType}
onChange={(e) => {
setTreatmentType(e.target.value);
setPage(1);
}}
className={filterSelectClass}
>
<option value="">{t('filterTreatmentTypeAll')}</option>
{filterOptions.treatmentTypes.map((type) => (
<option key={type.code} value={type.code}>
{treatmentLabel(type.code)}
</option>
))}
</select>
</label>
<label className="space-y-1">
<span className="text-xs font-medium text-text-muted">{t('filterSentFrom')}</span>
<input
type="date"
value={sentFrom}
onChange={(e) => {
setSentFrom(e.target.value);
setPage(1);
}}
className={filterSelectClass}
/>
</label>
<label className="space-y-1">
<span className="text-xs font-medium text-text-muted">{t('filterSentTo')}</span>
<input
type="date"
value={sentTo}
onChange={(e) => {
setSentTo(e.target.value);
setPage(1);
}}
className={filterSelectClass}
/>
</label>
</div>
{hasActiveFilters ? (
<Button variant="ghost" size="sm" onClick={clearFilters} className="self-start">
{t('clearFilters')}
</Button>
) : null}
<div className="flex-1 min-h-0">
{loadingList ? (
<p className="text-sm text-text-muted">{tCommon('loading')}</p>
) : cases.length === 0 ? (
<p className="text-sm text-text-muted">{t('emptyList')}</p>
) : (
<ul className="space-y-2 max-h-[55vh] overflow-y-auto pr-1">
{cases.map((item) => {
const isActive = item.id === selectedCaseId;
return (
<li key={item.id}>
<button
type="button"
onClick={() => {
setSelectedCaseId(item.id);
setMobileDetailOpen(true);
}}
className={`w-full rounded-md border px-3 py-2.5 text-left transition-colors ${
isActive
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary/40'
}`}
>
<div className="font-medium text-text-primary">
{formatPatientName(item.patient)}
</div>
<div className="text-xs text-text-muted mt-0.5">
{item.patient.mobile}
</div>
<div className="text-xs text-text-muted mt-0.5">{item.clinic.name}</div>
<div className="text-xs text-text-muted mt-1">
{formatCaseDateTime(item.sentAt, locale)}
</div>
<div className="text-xs text-text-muted mt-1 truncate">
{item.treatmentType ? treatmentLabel(item.treatmentType) : '—'}
</div>
<div className="mt-2">
<CaseTaskProgressBar
completed={item.taskProgress.completed}
total={item.taskProgress.total}
/>
</div>
</button>
</li>
);
})}
</ul>
)}
</div>
{pagination.totalPages > 1 ? (
<div className="flex items-center justify-between gap-2 pt-2 border-t border-border">
<Button
variant="outline"
size="sm"
disabled={page <= 1 || loadingList}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
{t('prevPage')}
</Button>
<span className="text-xs text-text-muted text-center">
{t('pageSummary', {
page: pagination.page,
totalPages: pagination.totalPages,
total: pagination.total,
})}
</span>
<Button
variant="outline"
size="sm"
disabled={page >= pagination.totalPages || loadingList}
onClick={() => setPage((p) => p + 1)}
>
{t('nextPage')}
</Button>
</div>
) : null}
</section>
<section
className={`rounded-lg border border-border bg-surface p-3 sm:p-4 min-h-[320px] lg:min-h-[420px] ${
selectedCaseId && !mobileDetailOpen ? 'hidden lg:block' : ''
}`}
>
{mobileDetailOpen && selectedCaseId ? (
<MobileDetailBackButton onClick={() => setMobileDetailOpen(false)} />
) : null}
{!selectedCaseId ? (
<p className="text-sm text-text-muted">{t('selectCaseHint')}</p>
) : loadingDetail || !selectedCase ? (
<p className="text-sm text-text-muted">{tCommon('loading')}</p>
) : (
<CaseDetailPanel
labCase={selectedCase}
locale={locale}
treatmentLabel={treatmentLabel}
statusOptions={statusOptions}
loadAttachmentBlob={loadCaseAttachmentBlob}
showCommentsButton
commentCount={commentCount}
onCommentsClick={scrollToComments}
canEditImportant={canEdit}
updatingImportant={updatingImportant}
onImportantChange={(checked) => void handleCaseImportantToggle(checked)}
headerMetaLines={
<p className="text-sm text-text-muted">
{t('fromClinic', { name: selectedCase.clinic.name })}
</p>
}
commentsSection={
selectedCaseId ? (
<section id="case-comments" className="scroll-mt-4 border-t border-border pt-4">
<LabCaseCommentsPanel
caseId={selectedCaseId}
canPost={canEditComments}
canToggleVisibility={canEditComments}
loadComments={async () => {
const r = await tasksApi.listComments(selectedCaseId);
setCommentCount(r.data.length);
return r.data;
}}
onPost={async (body, visibleToClinic) => {
const r = await tasksApi.addComment(selectedCaseId, {
body,
visibleToClinic,
});
setCommentCount((n) => n + 1);
return r.data;
}}
onToggleVisibility={async (commentId, visible) => {
const r = await tasksApi.setCommentVisibility(commentId, visible);
return r.data;
}}
onError={toast.showError}
/>
</section>
) : null
}
/>
)}
</section>
</div>
<ToastStack {...toast.messages} />
</div>
);
}
export default function Page() {
return <CasesPage />;
}

View File

@@ -1,602 +1,7 @@
'use client';
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { useToast } from '@/lib/hooks/useToast';
import { Check, History, 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 { OrganizationConnectionsMobileList } from '@/components/ui/organizations/OrganizationConnectionsMobileList';
import { InvitationHistoryDialog } from '@/components/ui/organizations/InvitationHistoryDialog';
import { ConnectionCaseHistoryContent } from '@/components/ui/organizations/ConnectionCaseHistoryContent';
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 { getUserFacingError } from '@/components/shared/formatApiError';
import { OrganizationsPage } from '@/components/ui/organizations/OrganizationsPage';
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 tErrors = useTranslations('errors');
const tNav = useTranslations('nav');
const tCommon = useTranslations('common');
const { currentOrganization } = useAuth();
const [loading, setLoading] = useState(true);
const toast = useToast();
const { showError, setError: setToastError } = toast;
const formatApiMessage = useCallback(
(err: unknown): string => getUserFacingError(err, tErrors, tCommon('errorGeneric')),
[tCommon, tErrors],
);
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 [caseHistoryConnection, setCaseHistoryConnection] = useState<CounterpartItemDto | null>(
null,
);
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();
}, []);
useEffect(() => {
let cancelled = false;
const q = query.trim();
if (!q) {
setMode('existing');
setSearchResults([]);
setShowInviteForm(false);
setSearching(false);
return;
}
setMode('search');
setShowInviteForm(false);
setSearching(true);
const timeout = setTimeout(() => {
void (async () => {
setToastError('');
try {
const res = await organizationApi.search(q);
if (cancelled) return;
setSearchResults(res.data);
} catch (e) {
if (cancelled) return;
showError(formatApiMessage(e));
setSearchResults([]);
} finally {
if (!cancelled) setSearching(false);
}
})();
}, 300);
return () => {
cancelled = true;
clearTimeout(timeout);
};
}, [query, formatApiMessage, showError, setToastError]);
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>;
}
if (caseHistoryConnection) {
return (
<ConnectionCaseHistoryContent
connection={caseHistoryConnection}
onBack={() => setCaseHistoryConnection(null)}
/>
);
}
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-xl sm: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" className="w-full sm:w-auto shrink-0" onClick={() => void openInvitationHistory()}>
{t('invitationHistory')}
</Button>
</div>
{!historyOpen && <ToastStack {...toast.messages} />}
<SearchBar
value={query}
onChange={setQuery}
placeholder={t('searchPlaceholder', { counterpart: counterpart.toLowerCase() })}
actions={
query.trim() ? (
<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>
) : undefined
}
/>
<OrganizationConnectionsMobileList
loading={loading || searching}
mode={mode}
existingRows={existingRows}
searchResults={searchResults}
currentOrganizationId={currentOrganization.id}
counterpart={counterpart}
pendingConnectionRowId={pendingConnectionRowId}
deleteConnectionRowId={deleteConnectionRowId}
copiedId={copiedId}
copyingInvitationId={copyingInvitationId}
showInviteForm={showInviteForm}
manualOrganizationName={manualOrganizationName}
manualOwnerEmail={manualOwnerEmail}
inviteLoading={inviteLoading}
formatConnectionStatusLabel={formatConnectionStatusLabel}
formatTableDate={formatTableDate}
getInvitationTarget={(row) => invitationTargetFromConnectionRow(row, currentOrganization.id)}
onCopyInvitation={(row) => void handleCopyInvitationFromRow(row)}
onRespond={(rowId, action) => void respondToPendingConnection(rowId, action)}
onViewCaseHistory={setCaseHistoryConnection}
onDeleteConnection={(rowId) => void deleteConnection(rowId)}
onSendConnectionRequest={(orgId) => void submitConnectionRequest(orgId)}
onToggleInviteForm={() => setShowInviteForm((v) => !v)}
onManualOrganizationNameChange={setManualOrganizationName}
onManualOwnerEmailChange={setManualOwnerEmail}
onSendInvite={() => void sendInvite()}
labels={{
loading: tCommon('loadingEllipsis'),
emptyConnections: t('emptyConnections'),
noDirectoryResults: t('noDirectoryResults'),
hideInvitationFields: t('hideInvitationFields'),
sendInvitationLink: t('sendInvitationLink'),
counterpartNameLabel: t('counterpartNameLabel', { counterpart }),
ownerEmailLabel: t('ownerEmailLabel'),
sendInvitation: t('sendInvitation'),
sendRequest: t('sendRequest'),
acceptRequest: t('acceptRequest'),
declineRequest: t('declineRequest'),
viewCaseHistory: t('viewCaseHistory'),
removeConnection: t('removeConnection'),
statusToday: t('statusToday'),
statusFound: t('statusFound'),
}}
/>
<div className="hidden lg:block">
<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 || (mode === 'search' && searching) ? (
<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-background-card/80 hover:text-text-primary"
onClick={() => setCaseHistoryConnection(row)}
aria-label={t('viewCaseHistory')}
title={t('viewCaseHistory')}
>
<History 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={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>
)}
</>
}
/>
</div>
<InvitationHistoryDialog
open={historyOpen}
onClose={() => setHistoryOpen(false)}
loading={historyLoading}
items={historyItems}
copiedId={copiedId}
copyingInvitationId={copyingInvitationId}
onCopy={(invitation) => void handleHistoryCopy(invitation)}
toastMessages={toast.messages}
/>
</div>
);
}
export default function Page() {
return <OrganizationsPage />;
}

View File

@@ -1,165 +1,7 @@
'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 { getUserFacingError } 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';
import { PatientAppointmentHistory } from '@/components/ui/patient/PatientAppointmentHistory';
import { PatientsPage } from '@/components/ui/patient/PatientsPage';
const EMPTY_PATIENT_FORM: CreatePatientInput = {
firstName: '',
lastName: '',
mobile: '',
email: '',
};
export default function PatientsPage() {
const t = useTranslations('patients');
const tErrors = useTranslations('errors');
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(getUserFacingError(error, tErrors, t('errorLoadPatients')));
} 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);
if (response.existing) {
toast.showInfo(
t('patientAlreadyExists', {
firstName: response.data.firstName,
lastName: response.data.lastName,
}),
);
} else {
toast.showSuccess(
t('successPatientSaved', {
firstName: response.data.firstName,
lastName: response.data.lastName,
}),
);
}
} catch (error: unknown) {
toast.showError(getUserFacingError(error, tErrors, t('errorSavePatient')));
} finally {
setSavingPatient(false);
}
}
return (
<div className="space-y-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h1 className="text-xl sm:text-2xl font-semibold text-text-primary">{t('title')}</h1>
<Button
variant="primary"
disabled={!canEditPatients}
onClick={() => {
if (!canEditPatients) return;
toast.clear();
setPatientForm(EMPTY_PATIENT_FORM);
setIsCreateOpen(true);
}}
title={!canEditPatients ? tCommon('readOnlyAccess') : undefined}
>
{t('newPatient')}
</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} />
{selectedPatient ? (
<PatientAppointmentHistory patientId={selectedPatient.id} />
) : null}
</div>
</div>
</div>
);
}
export default function Page() {
return <PatientsPage />;
}

View File

@@ -1,452 +1,7 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { useTranslations } from 'next-intl';
import { Link, useRouter } from '@/i18n/navigation';
import { useSearchParams } from 'next/navigation';
import { ChevronDown, Lock } from 'lucide-react';
import { useAuth } from '@/lib/hooks/useAuth';
import { authApi } from '@/lib/api/auth';
import { accountApi } from '@/lib/api/account';
import { Button } from '@/components/ui/shared/Button';
import { Input } from '@/components/ui/shared/Input';
import { Toast } from '@/components/ui/shared/Toast';
import { Checkbox } from '@/components/ui/shared/Checkbox';
import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
import { getUserFacingError } from '@/components/shared/formatApiError';
import { OwnerWorkingHoursDialog } from '@/components/settings/OwnerWorkingHoursDialog';
import { AccountSettingsPage } from '@/components/ui/settings/AccountSettingsPage';
type PasswordForm = {
currentPassword: string;
newPassword: string;
confirmPassword: string;
};
export default function AccountSettingsPage() {
const t = useTranslations('settings');
const tErrors = useTranslations('errors');
const tAuth = useTranslations('auth');
const tCommon = useTranslations('common');
const tValidation = useTranslations('validation');
const { user, currentOrganization, isAuthReady, refreshSession } = useAuth();
const router = useRouter();
const searchParams = useSearchParams();
const isResetFlow = searchParams.get('reset') === '1';
const isOwner = Boolean(currentOrganization?.isOwner);
const orgType = currentOrganization?.type;
const showClinicParticipation = isOwner && orgType === 'CLINIC';
const showLabParticipation = isOwner && orgType === 'LAB';
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [passwordExpanded, setPasswordExpanded] = useState(isResetFlow);
const [participationLoading, setParticipationLoading] = useState(false);
const [participatesInTreatments, setParticipatesInTreatments] = useState(false);
const [participatesInTasks, setParticipatesInTasks] = useState(false);
const [workingHoursOpen, setWorkingHoursOpen] = useState(false);
const [revokeConfirmOpen, setRevokeConfirmOpen] = useState(false);
const [pendingRevokeType, setPendingRevokeType] = useState<'CLINIC' | 'LAB' | null>(null);
const passwordSchema = useMemo(
() =>
z
.object({
currentPassword: z.string(),
newPassword: z
.string()
.min(8, tValidation('passwordMinLength'))
.regex(/[A-Z]/, tValidation('passwordUppercase'))
.regex(/[0-9]/, tValidation('passwordNumber')),
confirmPassword: z.string(),
})
.refine((data) => data.newPassword === data.confirmPassword, {
message: tValidation('passwordsDoNotMatch'),
path: ['confirmPassword'],
})
.superRefine((data, ctx) => {
if (!isResetFlow && !data.currentPassword.trim()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: tValidation('passwordRequired'),
path: ['currentPassword'],
});
}
}),
[isResetFlow, tValidation],
);
const {
register,
handleSubmit,
reset,
formState: { errors },
} = useForm<PasswordForm>({
resolver: zodResolver(passwordSchema),
defaultValues: {
currentPassword: '',
newPassword: '',
confirmPassword: '',
},
});
const loadParticipation = useCallback(async () => {
if (!isOwner) return;
try {
const res = await accountApi.getParticipation();
setParticipatesInTreatments(res.data.participatesInTreatments);
setParticipatesInTasks(res.data.participatesInTasks);
} catch {
/* non-owners or missing org context */
}
}, [isOwner]);
useEffect(() => {
if (isAuthReady && !user) {
router.replace('/login');
}
}, [isAuthReady, user, router]);
useEffect(() => {
if (isResetFlow) {
setPasswordExpanded(true);
}
}, [isResetFlow]);
useEffect(() => {
void loadParticipation();
}, [loadParticipation, currentOrganization?.id]);
const syncSessionAfterParticipationChange = useCallback(async () => {
await refreshSession();
}, [refreshSession]);
const enableClinicParticipation = useCallback(
async (options: {
skipHours: boolean;
hoursPayload?: {
autoRepeatWeekly: boolean;
blocks: { dayOfWeek: number; startMinute: number; endMinute: number; sortOrder?: number }[];
};
}) => {
await accountApi.updateParticipation(true);
if (!options.skipHours && options.hoursPayload) {
await accountApi.upsertMyWorkingHours(options.hoursPayload);
}
setParticipatesInTreatments(true);
await syncSessionAfterParticipationChange();
setSuccessMessage(t('participateEnabledTreatments'));
},
[syncSessionAfterParticipationChange, t],
);
const enableLabParticipation = async () => {
setParticipationLoading(true);
setError(null);
try {
await accountApi.updateParticipation(true);
setParticipatesInTasks(true);
await syncSessionAfterParticipationChange();
setSuccessMessage(t('participateEnabledTasks'));
} catch (err: unknown) {
setError(getUserFacingError(err, tErrors, t('participateUpdateFailed')));
setParticipatesInTasks(false);
} finally {
setParticipationLoading(false);
}
};
const confirmRevokeParticipation = async () => {
if (!pendingRevokeType) return;
setParticipationLoading(true);
setError(null);
try {
await accountApi.updateParticipation(false);
if (pendingRevokeType === 'CLINIC') {
setParticipatesInTreatments(false);
} else {
setParticipatesInTasks(false);
}
await syncSessionAfterParticipationChange();
setSuccessMessage(
pendingRevokeType === 'CLINIC'
? t('participateDisabledTreatments')
: t('participateDisabledTasks'),
);
setRevokeConfirmOpen(false);
setPendingRevokeType(null);
} catch (err: unknown) {
setError(getUserFacingError(err, tErrors, t('participateUpdateFailed')));
} finally {
setParticipationLoading(false);
}
};
const handleClinicParticipationChange = (checked: boolean) => {
setError(null);
if (checked) {
setWorkingHoursOpen(true);
return;
}
setPendingRevokeType('CLINIC');
setRevokeConfirmOpen(true);
};
const handleWorkingHoursClose = useCallback(() => {
setWorkingHoursOpen(false);
}, []);
const handleWorkingHoursComplete = useCallback(
async (options: {
skipHours: boolean;
hoursPayload?: {
autoRepeatWeekly: boolean;
blocks: { dayOfWeek: number; startMinute: number; endMinute: number; sortOrder?: number }[];
};
}) => {
setParticipationLoading(true);
setError(null);
try {
await enableClinicParticipation(options);
} catch (err: unknown) {
setError(getUserFacingError(err, tErrors, t('participateUpdateFailed')));
throw err;
} finally {
setParticipationLoading(false);
}
},
[enableClinicParticipation, t],
);
const handleLabParticipationChange = (checked: boolean) => {
setError(null);
if (checked) {
void enableLabParticipation();
return;
}
setPendingRevokeType('LAB');
setRevokeConfirmOpen(true);
};
const onSubmit = async (data: PasswordForm) => {
try {
setError(null);
setSuccessMessage(null);
setIsSubmitting(true);
await authApi.changePassword({
...(isResetFlow ? {} : { currentPassword: data.currentPassword }),
newPassword: data.newPassword,
});
reset();
setSuccessMessage(t('passwordChanged'));
router.replace('/login');
} catch (err: unknown) {
setError(getUserFacingError(err, tErrors, t('passwordChangeFailed')));
} finally {
setIsSubmitting(false);
}
};
const passwordToggleLabels = {
show: tAuth('showPassword'),
hide: tAuth('hidePassword'),
};
if (!isAuthReady || !user) {
return (
<p className="text-text-secondary text-sm">{tCommon('loadingEllipsis')}</p>
);
}
return (
<div className="space-y-6">
<div>
<Link href="/today" className="text-sm text-primary hover:opacity-90">
{tCommon('backToApp')}
</Link>
<h1 className="text-xl sm: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>
{(showClinicParticipation || showLabParticipation) && (
<div className="surface-card p-4 sm:p-6 max-w-lg space-y-4">
<div>
<h2 className="text-lg font-medium text-text-primary">{t('participationSectionTitle')}</h2>
<p className="text-sm text-text-secondary mt-1">{t('participationSectionSubtitle')}</p>
</div>
{showClinicParticipation && (
<Checkbox
checked={participatesInTreatments}
onChange={handleClinicParticipationChange}
disabled={participationLoading}
label={t('participateInTreatments')}
/>
)}
{showLabParticipation && (
<Checkbox
checked={participatesInTasks}
onChange={handleLabParticipationChange}
disabled={participationLoading}
label={t('participateInTasks')}
/>
)}
</div>
)}
<div className="surface-card max-w-lg overflow-hidden">
<button
type="button"
className="w-full flex items-center justify-between gap-3 p-4 sm:p-6 text-left hover:bg-background-card/40 transition-colors"
onClick={() => setPasswordExpanded((open) => !open)}
aria-expanded={passwordExpanded}
>
<div className="flex items-center gap-3 min-w-0">
<Lock className="h-5 w-5 icon-flat shrink-0" />
<div>
<p className="text-base font-medium text-text-primary">{t('changePasswordOption')}</p>
<p className="text-sm text-text-secondary truncate">
{user.email}
{user.mobile ? ` · ${user.mobile}` : ''}
</p>
</div>
</div>
<ChevronDown
className={`h-5 w-5 text-text-muted shrink-0 transition-transform ${
passwordExpanded ? 'rotate-180' : ''
}`}
/>
</button>
{passwordExpanded && (
<div className="border-t border-border px-4 sm:px-6 pb-4 sm:pb-6 pt-4">
<h2 className="text-lg font-medium text-text-primary mb-1">
{isResetFlow ? t('resetPasswordTitle') : t('changePasswordTitle')}
</h2>
{isResetFlow && (
<p className="text-sm text-text-secondary mb-4">{t('resetPasswordSubtitle')}</p>
)}
<form className="space-y-5" onSubmit={handleSubmit(onSubmit)}>
{!isResetFlow && (
<Input
label={t('currentPassword')}
{...register('currentPassword')}
type="password"
placeholder={tAuth('passwordPlaceholder')}
error={errors.currentPassword?.message}
icon={<Lock className="h-5 w-5 icon-flat" />}
passwordToggleLabels={passwordToggleLabels}
/>
)}
<Input
label={t('newPassword')}
{...register('newPassword')}
type="password"
placeholder={tAuth('passwordPlaceholder')}
error={errors.newPassword?.message}
icon={<Lock className="h-5 w-5 icon-flat" />}
passwordToggleLabels={passwordToggleLabels}
/>
<Input
label={t('confirmNewPassword')}
{...register('confirmPassword')}
type="password"
placeholder={tAuth('passwordPlaceholder')}
error={errors.confirmPassword?.message}
icon={<Lock className="h-5 w-5 icon-flat" />}
passwordToggleLabels={passwordToggleLabels}
/>
{error && (
<div className="p-3 bg-red-950/30 border border-red-600/40 rounded-[var(--radius-md)]">
<p className="text-sm text-red-600">{error}</p>
</div>
)}
<Button type="submit" variant="primary" isLoading={isSubmitting}>
{isResetFlow ? t('setNewPassword') : t('updatePassword')}
</Button>
</form>
</div>
)}
</div>
{error && !passwordExpanded && (
<div className="max-w-lg p-3 bg-red-950/30 border border-red-600/40 rounded-[var(--radius-md)]">
<p className="text-sm text-red-600">{error}</p>
</div>
)}
<OwnerWorkingHoursDialog
open={workingHoursOpen}
onClose={handleWorkingHoursClose}
onComplete={handleWorkingHoursComplete}
/>
{revokeConfirmOpen && (
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-0 sm:p-4 bg-black/55">
<div
className="surface-card w-full sm:max-w-md max-h-[90dvh] overflow-y-auto p-4 sm:p-5 space-y-4 shadow-xl rounded-t-[var(--radius-lg)] sm:rounded-[var(--radius-lg)]"
role="dialog"
aria-modal="true"
aria-labelledby="revoke-participation-title"
>
<div className="flex items-start justify-between gap-2">
<h2 id="revoke-participation-title" className="text-lg font-semibold text-text-primary pr-2">
{t('participateConfirmRevokeTitle')}
</h2>
<DialogCloseButton
onClick={() => {
if (participationLoading) return;
setRevokeConfirmOpen(false);
setPendingRevokeType(null);
}}
/>
</div>
<p className="text-sm text-text-secondary">
{pendingRevokeType === 'CLINIC'
? t('participateConfirmRevokeBodyTreatments')
: t('participateConfirmRevokeBodyTasks')}
</p>
<div className="flex flex-col-reverse sm:flex-row sm:justify-end gap-2 pt-1">
<Button
type="button"
variant="outline"
disabled={participationLoading}
onClick={() => {
setRevokeConfirmOpen(false);
setPendingRevokeType(null);
}}
>
{tCommon('cancel')}
</Button>
<Button
type="button"
variant="primary"
isLoading={participationLoading}
onClick={() => void confirmRevokeParticipation()}
>
{t('participateConfirmRevokeConfirm')}
</Button>
</div>
</div>
</div>
)}
{successMessage && (
<Toast variant="success">{successMessage}</Toast>
)}
</div>
);
}
export default function Page() {
return <AccountSettingsPage />;
}

View File

@@ -1,204 +1,7 @@
'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';
import { SubscriptionsPage } from '@/components/ui/settings/SubscriptionsPage';
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-xl sm: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-4 sm: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 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>
);
}
export default function Page() {
return <SubscriptionsPage />;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,403 +1,7 @@
'use client';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslations } from 'next-intl';
import { MessageSquare } from 'lucide-react';
import { ToastStack } from '@/components/ui/shared/Toast';
import { Badge } from '@/components/ui/shared/Badge';
import { Button } from '@/components/ui/shared/Button';
import { FORM_SELECT_CLASS } from '@/components/ui/shared/formSelectStyles';
import { SearchBar } from '@/components/ui/shared/SearchBar';
import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel';
import {
labTaskStatusSelectStyle,
labTaskStatusVariant,
} from '@/components/ui/lab/labTaskStatusDisplay';
import {
formatToothList,
prosthesisTypeBadgeStyle,
} from '@/components/ui/treatment/prosthesisTypeDisplay';
import { getUserFacingError } from '@/components/shared/formatApiError';
import { canEditTasks, canViewTasks } from '@/components/shared/permissions';
import { useAuth } from '@/lib/hooks/useAuth';
import { useToast } from '@/lib/hooks/useToast';
import { tasksApi } from '@/lib/api/tasks';
import type {
LabTaskListItem,
LabTaskStatus,
ListLabTasksParams,
PaginatedLabTasks,
TaskSortField,
} from '@/types/cases';
import { TasksPage } from '@/components/ui/lab/TasksPage';
const PAGE_SIZE = 50;
function formatPatientName(patient: { firstName: string; lastName: string }) {
return `${patient.firstName} ${patient.lastName}`.trim();
}
export default function TasksPage() {
const t = useTranslations('tasks');
const tErrors = useTranslations('errors');
const { currentOrganization, user, isAuthReady } = useAuth();
const { showError, setError, messages: toastMessages } = useToast();
const [tasks, setTasks] = useState<LabTaskListItem[]>([]);
const [pagination, setPagination] = useState<PaginatedLabTasks['pagination']>({
page: 1,
limit: PAGE_SIZE,
total: 0,
totalPages: 1,
});
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [updatingTaskId, setUpdatingTaskId] = useState<string | null>(null);
const [expandedCommentsTaskId, setExpandedCommentsTaskId] = useState<string | null>(null);
const [search, setSearch] = useState('');
const [clinicId, setClinicId] = useState('');
const [statusFilter, setStatusFilter] = useState<'' | LabTaskStatus>('IN_PROGRESS');
const [sentFrom, setSentFrom] = useState('');
const [sentTo, setSentTo] = useState('');
const [sortBy, setSortBy] = useState<TaskSortField>('date');
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
const canView = canViewTasks(currentOrganization);
const canEdit = canEditTasks(currentOrganization);
const locale = user?.language ?? 'en';
const tRef = useRef(t);
tRef.current = t;
const statusOptions: { value: LabTaskStatus; label: string }[] = useMemo(
() => [
{ value: 'IN_PROGRESS', label: t('statusInProgress') },
{ value: 'COMPLETED', label: t('statusCompleted') },
],
[t],
);
const listParams = useMemo((): ListLabTasksParams => {
const params: ListLabTasksParams = {
page,
limit: PAGE_SIZE,
sortBy,
sortDir,
};
if (search.trim()) params.q = search.trim();
if (clinicId) params.clinicOrganizationId = clinicId;
if (statusFilter) params.status = statusFilter;
if (sentFrom) params.sentFrom = sentFrom;
if (sentTo) params.sentTo = sentTo;
return params;
}, [page, search, clinicId, statusFilter, sentFrom, sentTo, sortBy, sortDir]);
const clinicOptions = useMemo(() => {
const map = new Map<string, string>();
for (const task of tasks) {
map.set(task.clinic.id, task.clinic.name);
}
return [...map.entries()].map(([id, name]) => ({ id, name }));
}, [tasks]);
const loadTasks = useCallback(async () => {
setLoading(true);
setError('');
try {
const response = await tasksApi.list(listParams);
setTasks(response.data.items);
setPagination(response.data.pagination);
} catch (error: unknown) {
showError(getUserFacingError(error, tErrors, tRef.current('errorLoadList')));
} finally {
setLoading(false);
}
}, [listParams, showError, setError]);
useEffect(() => {
if (!canView) return;
const timeout = setTimeout(() => void loadTasks(), search ? 300 : 0);
return () => clearTimeout(timeout);
}, [canView, loadTasks, search]);
async function handleStatusUpdate(taskId: string, status: LabTaskStatus) {
if (!canEdit) return;
setUpdatingTaskId(taskId);
setError('');
try {
await tasksApi.updateStatus(taskId, status);
await loadTasks();
} catch (error: unknown) {
showError(getUserFacingError(error, tErrors, t('errorUpdateTask')));
} finally {
setUpdatingTaskId(null);
}
}
function formatTaskDate(value: string) {
return new Intl.DateTimeFormat(locale, {
year: 'numeric',
month: 'short',
day: 'numeric',
}).format(new Date(value));
}
const filterSelectClass = `${FORM_SELECT_CLASS} w-full rounded-md px-2 py-1.5 text-sm`;
if (!isAuthReady) {
return <div className="text-sm text-text-muted">{t('loading')}</div>;
}
if (!canView) {
return (
<div className="surface-card p-6 max-w-xl">
<h2 className="text-lg font-semibold text-text-primary">{t('noPermissionTitle')}</h2>
<p className="text-sm text-text-secondary mt-2">{t('noPermissionBody')}</p>
</div>
);
}
return (
<div className="space-y-4">
<header className="space-y-1">
<h1 className="text-xl sm:text-2xl font-semibold text-text-primary">{t('title')}</h1>
<p className="text-sm text-text-secondary">{t('subtitle')}</p>
</header>
<section className="surface-card p-3 space-y-3">
<SearchBar
embedded
value={search}
onChange={(v) => {
setSearch(v);
setPage(1);
}}
placeholder={t('searchPlaceholder')}
/>
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
<label className="space-y-1">
<span className="text-xs text-text-muted">{t('filterClinic')}</span>
<select
value={clinicId}
onChange={(e) => {
setClinicId(e.target.value);
setPage(1);
}}
className={filterSelectClass}
>
<option value="">{t('filterClinicAll')}</option>
{clinicOptions.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</label>
<label className="space-y-1">
<span className="text-xs text-text-muted">{t('filterStatus')}</span>
<select
value={statusFilter}
onChange={(e) => {
setStatusFilter(e.target.value as '' | LabTaskStatus);
setPage(1);
}}
className={filterSelectClass}
>
<option value="">{t('filterStatusAll')}</option>
{statusOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</label>
<label className="space-y-1">
<span className="text-xs text-text-muted">{t('sortBy')}</span>
<div className="flex gap-1.5">
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as TaskSortField)}
className={`${filterSelectClass} min-w-0 flex-1`}
>
<option value="date">{t('sortDate')}</option>
<option value="clinic">{t('sortClinic')}</option>
<option value="patient">{t('sortPatient')}</option>
<option value="prosthesis">{t('sortProsthesis')}</option>
<option value="taskType">{t('sortTaskType')}</option>
</select>
<select
value={sortDir}
onChange={(e) => setSortDir(e.target.value as 'asc' | 'desc')}
className={`${FORM_SELECT_CLASS} w-14 shrink-0 rounded-md px-2 py-1.5 text-sm`}
aria-label={t('sortDirection')}
>
<option value="desc"></option>
<option value="asc"></option>
</select>
</div>
</label>
</div>
</section>
<section className="surface-card min-h-[280px]">
{loading && tasks.length === 0 ? (
<p className="p-3 text-sm text-text-muted">{t('loading')}</p>
) : tasks.length === 0 ? (
<p className="p-3 text-sm text-text-muted">{t('emptyList')}</p>
) : (
<ul className="divide-y divide-border">
{tasks.map((task, index) => {
const commentsOpen = expandedCommentsTaskId === task.id;
return (
<li key={task.id}>
<div className="flex flex-col gap-3 px-3 py-3 sm:grid sm:grid-cols-[minmax(0,1fr)_132px_auto] sm:items-center sm:gap-x-3 sm:gap-y-0.5 sm:py-2">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-1.5">
<p className="text-sm font-medium text-text-primary">
{task.stepOrder}. {task.stepLabel}
</p>
{task.isImportant ? (
<Badge variant="warning" fixedWidth={false}>
{t('importantBadge')}
</Badge>
) : null}
</div>
<p className="text-[11px] text-text-secondary truncate">
{t('fromClinic', { name: task.clinic.name })} ·{' '}
{formatPatientName(task.patient)} ·{' '}
{t('teethLabel', { teeth: formatToothList(task.teeth) })}
</p>
<p className="text-[11px] text-text-muted truncate">
<span>{t('taskDate', { date: formatTaskDate(task.createdAt) })}</span>
{task.lastStatusChangedBy ? (
<>
<span aria-hidden> · </span>
<span>
{t('lastUpdatedBy', { name: task.lastStatusChangedBy.name })}
</span>
</>
) : null}
</p>
</div>
<div className="flex sm:justify-center">
{canEdit ? (
<select
value={task.status}
disabled={updatingTaskId === task.id}
onChange={(e) =>
void handleStatusUpdate(task.id, e.target.value as LabTaskStatus)
}
className={`${FORM_SELECT_CLASS} w-full sm:max-w-[132px] font-medium`}
style={labTaskStatusSelectStyle(task.status)}
>
{statusOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
) : (
<Badge variant={labTaskStatusVariant(task.status)} fixedWidth={false}>
{statusOptions.find((opt) => opt.value === task.status)?.label ??
task.status}
</Badge>
)}
</div>
<div className="flex items-center gap-1.5 shrink-0 justify-between sm:justify-end">
{canEdit ? (
<button
type="button"
onClick={() =>
setExpandedCommentsTaskId(commentsOpen ? null : task.id)
}
className={`p-1.5 rounded border ${
commentsOpen
? 'border-primary bg-primary/10 text-primary'
: 'border-border text-text-muted hover:border-primary/40'
}`}
title={t('commentsButton')}
>
<MessageSquare className="h-4 w-4" />
</button>
) : null}
<Badge
fixedWidth={false}
truncate
title={task.prosthesisTypeLabel}
style={prosthesisTypeBadgeStyle(task.prosthesisTypeCode, index)}
className="w-full max-w-[8rem] sm:w-[7rem]"
>
{task.prosthesisTypeLabel}
</Badge>
</div>
</div>
{commentsOpen && canEdit ? (
<div className="px-3 pb-3 border-t border-border/50">
<LabCaseCommentsPanel
caseId={task.labCaseId}
canPost
canToggleVisibility
loadComments={async () => {
const r = await tasksApi.listComments(task.labCaseId);
return r.data;
}}
onPost={async (body, visibleToClinic) => {
const r = await tasksApi.addComment(task.labCaseId, {
body,
visibleToClinic,
});
return r.data;
}}
onToggleVisibility={async (commentId, visible) => {
const r = await tasksApi.setCommentVisibility(commentId, visible);
return r.data;
}}
onError={showError}
/>
</div>
) : null}
</li>
);
})}
</ul>
)}
</section>
{pagination.totalPages > 1 && (
<div className="flex items-center justify-between gap-3 flex-wrap">
<p className="text-sm text-text-muted">
{t('pageSummary', {
page: pagination.page,
totalPages: pagination.totalPages,
total: pagination.total,
})}
</p>
<div className="flex gap-2">
<Button
type="button"
variant="secondary"
disabled={page <= 1 || loading}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
</Button>
<Button
type="button"
variant="secondary"
disabled={page >= pagination.totalPages || loading}
onClick={() => setPage((p) => p + 1)}
>
</Button>
</div>
</div>
)}
<ToastStack {...toastMessages} />
</div>
);
}
export default function Page() {
return <TasksPage />;
}

View File

@@ -1,80 +1,7 @@
'use client';
import { useMemo } from 'react';
import { useTranslations } from 'next-intl';
import { Link } from '@/i18n/navigation';
import { useAuth } from '@/lib/hooks/useAuth';
import { getUserFacingError } from '@/components/shared/formatApiError';
import { TodayDashboard } from '@/components/today/TodayDashboard';
import { TodayLoadErrorBanner } from '@/components/today/TodayLoadErrorBanner';
import { TodaySectionErrorFallback } from '@/components/today/TodaySectionErrorFallback';
import { TodayWidgetErrorBoundary } from '@/components/today/TodayWidgetErrorBoundary';
import { useTodaySummary } from '@/lib/hooks/useTodaySummary';
import { TodayPage } from '@/components/ui/today/TodayPage';
export default function TodayPage() {
const t = useTranslations('today');
const tErrors = useTranslations('errors');
const { currentOrganization } = useAuth();
const orgId = currentOrganization?.id;
const { data, loading, isInitialLoad, error, reload } = useTodaySummary(orgId);
const showNoSubscriptionNotice = useMemo(
() => Boolean(currentOrganization?.isOwner) && !currentOrganization?.plan,
[currentOrganization],
);
const sectionErrorMessage = t('sectionLoadError');
return (
<div className="space-y-6">
<div className="flex flex-col gap-1 sm:flex-row sm:items-end sm:justify-between">
<h1 className="text-2xl font-semibold">{t('welcomeBack')}</h1>
{data?.generatedAt && !isInitialLoad ? (
<p className="text-xs text-text-muted">
{t('lastUpdated', {
time: new Intl.DateTimeFormat(undefined, {
hour: 'numeric',
minute: '2-digit',
}).format(new Date(data.generatedAt)),
})}
</p>
) : null}
</div>
{showNoSubscriptionNotice && (
<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')}{' '}
<Link href="/settings/subscriptions" className="font-medium underline underline-offset-2">
{t('choosePlanLink')}
</Link>{' '}
{t('noSubscriptionCta')}
</p>
</div>
)}
{error ? (
<TodayLoadErrorBanner
message={getUserFacingError(error, tErrors, t('loadError'))}
retryLabel={t('retryLoad')}
onRetry={() => void reload()}
isRetrying={loading && Boolean(data)}
/>
) : null}
<TodayWidgetErrorBoundary
fallback={<TodaySectionErrorFallback message={sectionErrorMessage} />}
>
<TodayDashboard
widgets={data?.widgets ?? {}}
charts={data?.charts ?? {}}
actions={data?.actions ?? {}}
subscription={data?.subscription}
loading={loading}
isInitialLoad={isInitialLoad}
hasError={Boolean(error)}
/>
</TodayWidgetErrorBoundary>
</div>
);
}
export default function Page() {
return <TodayPage />;
}