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:
@@ -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 />;
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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
@@ -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 />;
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
Reference in New Issue
Block a user