bugfix/demo-bugs-fixed #19

Merged
admin merged 10 commits from bugfix/demo-bugs-fixed into master 2026-05-18 19:11:30 +03:30
51 changed files with 761 additions and 335 deletions

View File

@@ -126,7 +126,11 @@ export class AuthController {
@ApiBearerAuth('JWT-auth') @ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Create organization for current user' }) @ApiOperation({ summary: 'Create organization for current user' })
async createOrganization(@Req() req, @Body() dto: CreateOrganizationDto) { async createOrganization(@Req() req, @Body() dto: CreateOrganizationDto) {
return this.authService.createOrganization(req.user.id, dto); return this.authService.createOrganization(
req.user.id,
req.user.organizationId,
dto,
);
} }
// ========================= // =========================

View File

@@ -4,6 +4,7 @@ import {
UnauthorizedException, UnauthorizedException,
BadRequestException, BadRequestException,
ConflictException, ConflictException,
ForbiddenException,
InternalServerErrorException InternalServerErrorException
} from '@nestjs/common'; } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
@@ -270,7 +271,11 @@ export class AuthService {
return this.login({ email, password } as any, validatedUser); return this.login({ email, password } as any, validatedUser);
} }
async createOrganization(userId: string, dto: CreateOrganizationDto) { async createOrganization(
userId: string,
currentOrganizationId: string | undefined,
dto: CreateOrganizationDto,
) {
const owner = await this.prisma.user.findUnique({ const owner = await this.prisma.user.findUnique({
where: { id: userId }, where: { id: userId },
select: { id: true }, select: { id: true },
@@ -280,6 +285,28 @@ export class AuthService {
throw new UnauthorizedException('User not found'); throw new UnauthorizedException('User not found');
} }
if (!currentOrganizationId) {
throw new ForbiddenException(
'Select an organization before creating a new one.',
);
}
const currentMembership = await this.prisma.membership.findUnique({
where: {
userId_organizationId: {
userId,
organizationId: currentOrganizationId,
},
},
select: { isOwner: true },
});
if (!currentMembership?.isOwner) {
throw new ForbiddenException(
'Only owners of the current organization can create new organizations.',
);
}
const organization = await this.prisma.$transaction(async (tx) => { const organization = await this.prisma.$transaction(async (tx) => {
const createdOrganization = await tx.organization.create({ const createdOrganization = await tx.organization.create({
data: { data: {

View File

@@ -80,6 +80,32 @@ export class StaffController {
return this.staffService.updateMember(req.user.id, organizationId, membershipId, dto); return this.staffService.updateMember(req.user.id, organizationId, membershipId, dto);
} }
@Patch('members/:membershipId/enable')
@UseGuards(JwtAuthGuard)
@ApiOperation({
summary: 'Re-enable a disabled staff member (uses one plan seat; no new invitation)',
})
enableMember(
@Req() req: { user: { id: string; organizationId?: string } },
@Param('membershipId') membershipId: string,
) {
const organizationId = this.staffService.getOrganizationIdFromUser(req.user);
return this.staffService.enableMember(req.user.id, organizationId, membershipId);
}
@Patch('members/:membershipId/disable')
@UseGuards(JwtAuthGuard)
@ApiOperation({
summary: 'Disable staff member (frees a seat; member cannot access this organization)',
})
disableMember(
@Req() req: { user: { id: string; organizationId?: string } },
@Param('membershipId') membershipId: string,
) {
const organizationId = this.staffService.getOrganizationIdFromUser(req.user);
return this.staffService.disableMember(req.user.id, organizationId, membershipId);
}
@Delete('members/:membershipId') @Delete('members/:membershipId')
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@ApiOperation({ summary: 'Remove staff member from organization' }) @ApiOperation({ summary: 'Remove staff member from organization' })

View File

@@ -7,6 +7,7 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import * as bcrypt from 'bcrypt'; import * as bcrypt from 'bcrypt';
import { createHash, randomBytes } from 'crypto'; import { createHash, randomBytes } from 'crypto';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../../prisma/prisma.service'; import { PrismaService } from '../../../prisma/prisma.service';
import { AcceptStaffInviteDto } from './dto/accept-staff-invite.dto'; import { AcceptStaffInviteDto } from './dto/accept-staff-invite.dto';
import { isUnlimitedSeats, normalizeTabPermissions } from '../../common/permissions'; import { isUnlimitedSeats, normalizeTabPermissions } from '../../common/permissions';
@@ -399,6 +400,88 @@ export class StaffService {
return { success: true, message: 'Member updated' }; return { success: true, message: 'Member updated' };
} }
async enableMember(actorUserId: string, organizationId: string, membershipId: string) {
const actor = await this.getActorMembership(actorUserId, organizationId);
if (!actor || !this.canEditStaff(actor)) {
throw new ForbiddenException('You cannot manage staff');
}
const target = await this.prisma.membership.findFirst({
where: { id: membershipId, organizationId },
include: {
invitations: { orderBy: { createdAt: 'desc' }, take: 1 },
},
});
if (!target) {
throw new NotFoundException('Member not found');
}
if (target.isOwner) {
throw new ForbiddenException('Cannot enable the organization owner');
}
if (target.isActive) {
throw new BadRequestException('This member is already active');
}
const invitation = target.invitations[0];
if (invitation && !invitation.acceptedAt) {
throw new BadRequestException(
'This member has not completed their invitation yet. Share the invite link instead.',
);
}
await this.prisma.$transaction(async (tx) => {
await this.assertOrganizationHasAvailableSeat(organizationId, tx);
await tx.membership.update({
where: { id: membershipId },
data: { isActive: true },
});
});
return {
success: true,
message: 'Member enabled. They can sign in to this organization again.',
};
}
async disableMember(actorUserId: string, organizationId: string, membershipId: string) {
const actor = await this.getActorMembership(actorUserId, organizationId);
if (!actor || !this.canEditStaff(actor)) {
throw new ForbiddenException('You cannot manage staff');
}
const target = await this.prisma.membership.findFirst({
where: { id: membershipId, organizationId },
});
if (!target) {
throw new NotFoundException('Member not found');
}
if (target.isOwner) {
throw new ForbiddenException('Cannot disable the organization owner');
}
if (actorUserId === target.userId) {
throw new BadRequestException('You cannot disable your own access');
}
if (!target.isActive) {
throw new BadRequestException('This member is already disabled or pending activation');
}
await this.prisma.membership.update({
where: { id: membershipId },
data: { isActive: false },
});
await this.prisma.session.deleteMany({
where: { userId: target.userId },
});
return {
success: true,
message: 'Member disabled. Their seat is now available for another invite.',
};
}
async removeMember(actorUserId: string, organizationId: string, membershipId: string) { async removeMember(actorUserId: string, organizationId: string, membershipId: string) {
const actor = await this.getActorMembership(actorUserId, organizationId); const actor = await this.getActorMembership(actorUserId, organizationId);
if (!actor || !this.canEditStaff(actor)) { if (!actor || !this.canEditStaff(actor)) {
@@ -421,6 +504,37 @@ export class StaffService {
return { success: true, message: 'Member removed' }; return { success: true, message: 'Member removed' };
} }
private async assertOrganizationHasAvailableSeat(
organizationId: string,
db: Prisma.TransactionClient | PrismaService = this.prisma,
) {
const org = await db.organization.findUnique({
where: { id: organizationId },
include: { plan: true },
});
if (!org) {
throw new NotFoundException('Organization not found');
}
if (!org.plan) {
throw new BadRequestException(
'This organization has no active subscription. Please choose a plan before adding staff.',
);
}
const maxUsers = org.plan.maxUsers;
const seatsUsed = await db.membership.count({
where: {
organizationId,
OR: [{ isOwner: true }, { isActive: true }],
},
});
if (!isUnlimitedSeats(maxUsers) && seatsUsed >= maxUsers) {
throw new BadRequestException(
`Your plan allows ${maxUsers} team members. Free a seat by disabling another member or upgrade your plan.`,
);
}
}
private async getActorMembership(userId: string, organizationId: string) { private async getActorMembership(userId: string, organizationId: string) {
return this.prisma.membership.findFirst({ return this.prisma.membership.findFirst({
where: { userId, organizationId }, where: { userId, organizationId },
@@ -435,11 +549,12 @@ export class StaffService {
isOwner: boolean; isOwner: boolean;
isActive: boolean; isActive: boolean;
invitations: { acceptedAt: Date | null; revokedAt: Date | null; expiresAt: Date }[]; invitations: { acceptedAt: Date | null; revokedAt: Date | null; expiresAt: Date }[];
}): 'ACTIVE' | 'PENDING' | 'EXPIRED' { }): 'ACTIVE' | 'PENDING' | 'EXPIRED' | 'DISABLED' {
if (m.isOwner || m.isActive) return 'ACTIVE'; if (m.isOwner) return 'ACTIVE';
if (m.isActive) return 'ACTIVE';
const invitation = m.invitations[0]; const invitation = m.invitations[0];
if (!invitation) return 'EXPIRED'; if (invitation?.acceptedAt) return 'DISABLED';
if (invitation.acceptedAt) return 'ACTIVE'; if (!invitation) return 'DISABLED';
if (invitation.revokedAt) return 'EXPIRED'; if (invitation.revokedAt) return 'EXPIRED';
return invitation.expiresAt.getTime() > Date.now() ? 'PENDING' : 'EXPIRED'; return invitation.expiresAt.getTime() > Date.now() ? 'PENDING' : 'EXPIRED';
} }

View File

@@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { appointmentsApi } from '@/lib/api/appointments'; import { appointmentsApi } from '@/lib/api/appointments';
import { patientsApi } from '@/lib/api/patients'; import { patientsApi } from '@/lib/api/patients';
import { useAuth } from '@/lib/hooks/useAuth'; import { useAuth } from '@/lib/hooks/useAuth';
import { canEditAppointments, hasPermission } from '@/shared/permissions'; import { canEditAppointments, hasPermission } from '@/components/shared/permissions';
import type { AppointmentColumnProvider, AppointmentRecord } from '@/types/appointment'; import type { AppointmentColumnProvider, AppointmentRecord } from '@/types/appointment';
import type { CreatePatientInput, Patient } from '@/types/patient'; import type { CreatePatientInput, Patient } from '@/types/patient';
import { CreatePatientModal } from '@/components/ui/patient/CreatePatientModal'; import { CreatePatientModal } from '@/components/ui/patient/CreatePatientModal';
@@ -13,12 +13,12 @@ import { AppointmentBookingModal } from '@/components/ui/appointments/Appointmen
import { AppointmentScheduleGrid } from '@/components/ui/appointments/AppointmentScheduleGrid'; import { AppointmentScheduleGrid } from '@/components/ui/appointments/AppointmentScheduleGrid';
import { AppointmentsPatientSearch } from '@/components/ui/appointments/AppointmentsPatientSearch'; import { AppointmentsPatientSearch } from '@/components/ui/appointments/AppointmentsPatientSearch';
import { AppointmentScheduleLegend } from '@/components/ui/appointments/AppointmentScheduleLegend'; import { AppointmentScheduleLegend } from '@/components/ui/appointments/AppointmentScheduleLegend';
import { ScheduleDayPicker } from '@/components/ui/common/ScheduleDayPicker'; import { ScheduleDayPicker } from '@/components/ui/shared/ScheduleDayPicker';
import { ToastStack } from '@/components/ui/common/Toast'; import { ToastStack } from '@/components/ui/shared/Toast';
import { useToast } from '@/lib/hooks/useToast'; import { useToast } from '@/lib/hooks/useToast';
import type { AppointmentPurpose } from '@/types/appointment'; import type { AppointmentPurpose } from '@/types/appointment';
import { formatApiErrorMessage } from '@/lib/formatApiError'; import { formatApiErrorMessage } from '@/components/shared/formatApiError';
import { compareLocalDayStart, getLocalDayIsoRange, startOfLocalDay } from '@/lib/appointmentTime'; import { compareLocalDayStart, getLocalDayIsoRange, startOfLocalDay } from '@/components/appointments/appointmentTime';
const EMPTY_PATIENT_FORM: CreatePatientInput = { const EMPTY_PATIENT_FORM: CreatePatientInput = {
firstName: '', firstName: '',
@@ -273,6 +273,7 @@ export default function AppointmentsPage() {
if (!canEditPatients) { if (!canEditPatients) {
return; return;
} }
setPatientForm(EMPTY_PATIENT_FORM);
setIsCreateOpen(true); setIsCreateOpen(true);
}} }}
/> />
@@ -285,7 +286,6 @@ export default function AppointmentsPage() {
<div className="flex flex-col sm:flex-row sm:items-end gap-4 sm:justify-between"> <div className="flex flex-col sm:flex-row sm:items-end gap-4 sm:justify-between">
<ScheduleDayPicker <ScheduleDayPicker
value={scheduleDate} value={scheduleDate}
minDate={todayStart}
onChange={(d) => setScheduleDate(startOfLocalDay(d))} onChange={(d) => setScheduleDate(startOfLocalDay(d))}
/> />
{loadingSchedule && ( {loadingSchedule && (
@@ -323,18 +323,18 @@ export default function AppointmentsPage() {
deleting={deletingAppointment} deleting={deletingAppointment}
/> />
{isCreateOpen && ( <CreatePatientModal
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4 bg-black/55"> variant="dialog"
<CreatePatientModal isOpen={isCreateOpen}
isOpen={isCreateOpen} formData={patientForm}
formData={patientForm} onChange={(patch) => setPatientForm((prev) => ({ ...prev, ...patch }))}
onChange={(patch) => setPatientForm((prev) => ({ ...prev, ...patch }))} onSubmit={() => void handleCreatePatient()}
onSubmit={() => void handleCreatePatient()} onClose={() => {
onClose={() => setIsCreateOpen(false)} setIsCreateOpen(false);
loading={savingPatient} setPatientForm(EMPTY_PATIENT_FORM);
/> }}
</div> loading={savingPatient}
)} />
</div> </div>
); );

View File

@@ -2,13 +2,13 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import { Pencil } from 'lucide-react'; import { Pencil } from 'lucide-react';
import { Button } from '@/components/ui/common/Button'; import { Button } from '@/components/ui/shared/Button';
import { Badge } from '@/components/ui/common/Badge'; import { Badge } from '@/components/ui/shared/Badge';
import { Card } from '@/components/ui/common/Card'; import { Card } from '@/components/ui/shared/Card';
import { Table } from '@/components/ui/common/Table'; import { Table } from '@/components/ui/shared/Table';
import { SearchBar } from '@/components/ui/common/SearchBar'; import { SearchBar } from '@/components/ui/shared/SearchBar';
import { useAuth } from '@/lib/hooks/useAuth'; import { useAuth } from '@/lib/hooks/useAuth';
import { hasPermission } from '@/shared/permissions'; import { hasPermission } from '@/components/shared/permissions';
// Mock data matching your design // Mock data matching your design
const invoices = [ const invoices = [
{ id: '#123456', patient: 'Ali Rahmani', date: '24/9/2026', service: 'Hygiene', amount: 300, paid: 0, status: 'unpaid' }, { id: '#123456', patient: 'Ali Rahmani', date: '24/9/2026', service: 'Hygiene', amount: 300, paid: 0, status: 'unpaid' },

View File

@@ -3,15 +3,15 @@
import { memo, useEffect } from 'react'; import { memo, useEffect } from 'react';
import { usePathname, useRouter } from 'next/navigation'; import { usePathname, useRouter } from 'next/navigation';
import { useAuth } from '@/lib/hooks/useAuth'; import { useAuth } from '@/lib/hooks/useAuth';
import Sidebar from '@/components/ui/common/Sidebar'; import Sidebar from '@/components/ui/shared/Sidebar';
import { ThemeToggle } from '@/components/ui/common/ThemeToggle'; import { ThemeToggle } from '@/components/ui/shared/ThemeToggle';
import { DashboardAccountMenu } from '@/components/ui/dashboard/DashboardAccountMenu'; import { DashboardAccountMenu } from '@/components/ui/dashboard/DashboardAccountMenu';
import { import {
canAccessAppointmentsSection, canAccessAppointmentsSection,
firstAccessibleDashboardPath, firstAccessibleDashboardPath,
getRequiredReadPermissionForPath, getRequiredReadPermissionForPath,
hasPermission, hasPermission,
} from '@/shared/permissions'; } from '@/components/shared/permissions';
export default function DashboardLayout({ children }: { children: React.ReactNode }) { export default function DashboardLayout({ children }: { children: React.ReactNode }) {
const { user, currentOrganization, isAuthReady } = useAuth(); const { user, currentOrganization, isAuthReady } = useAuth();

View File

@@ -14,12 +14,12 @@ import {
import { invitationTargetFromConnectionRow } from '@/components/invitations/organizationInviteLinks'; import { invitationTargetFromConnectionRow } from '@/components/invitations/organizationInviteLinks';
import { CopyInvitationLinkButton } from '@/components/ui/organizations/CopyInvitationLinkButton'; import { CopyInvitationLinkButton } from '@/components/ui/organizations/CopyInvitationLinkButton';
import { InvitationHistoryDialog } from '@/components/ui/organizations/InvitationHistoryDialog'; import { InvitationHistoryDialog } from '@/components/ui/organizations/InvitationHistoryDialog';
import { Button } from '@/components/ui/common/Button'; import { Button } from '@/components/ui/shared/Button';
import { Badge, organizationConnectionStatusVariant } from '@/components/ui/common/Badge'; import { Badge, organizationConnectionStatusVariant } from '@/components/ui/shared/Badge';
import { Input } from '@/components/ui/common/Input'; import { Input } from '@/components/ui/shared/Input';
import { SearchBar } from '@/components/ui/common/SearchBar'; import { SearchBar } from '@/components/ui/shared/SearchBar';
import { Table } from '@/components/ui/common/Table'; import { Table } from '@/components/ui/shared/Table';
import { ToastStack } from '@/components/ui/common/Toast'; import { ToastStack } from '@/components/ui/shared/Toast';
import type { ApiError } from '@/types/api'; import type { ApiError } from '@/types/api';
function formatOrganizationStatusLabel(status: string): string { function formatOrganizationStatusLabel(status: string): string {

View File

@@ -1,10 +1,13 @@
'use client'; 'use client';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Button } from '@/components/ui/common/Button'; import { Button } from '@/components/ui/shared/Button';
import { ToastStack } from '@/components/ui/shared/Toast';
import { patientsApi } from '@/lib/api/patients'; import { patientsApi } from '@/lib/api/patients';
import { formatApiErrorMessage } from '@/components/shared/formatApiError';
import { useAuth } from '@/lib/hooks/useAuth'; import { useAuth } from '@/lib/hooks/useAuth';
import { hasPermission } from '@/shared/permissions'; import { useToast } from '@/lib/hooks/useToast';
import { hasPermission } from '@/components/shared/permissions';
import { import {
CreatePatientInput, CreatePatientInput,
CreateTreatmentHistoryInput, CreateTreatmentHistoryInput,
@@ -25,6 +28,7 @@ const EMPTY_PATIENT_FORM: CreatePatientInput = {
export default function PatientsPage() { export default function PatientsPage() {
const { currentOrganization } = useAuth(); const { currentOrganization } = useAuth();
const toast = useToast();
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [patients, setPatients] = useState<Patient[]>([]); const [patients, setPatients] = useState<Patient[]>([]);
const [selectedPatient, setSelectedPatient] = useState<Patient | undefined>(); const [selectedPatient, setSelectedPatient] = useState<Patient | undefined>();
@@ -35,8 +39,6 @@ export default function PatientsPage() {
const [savingPatient, setSavingPatient] = useState(false); const [savingPatient, setSavingPatient] = useState(false);
const [savingTreatment, setSavingTreatment] = useState(false); const [savingTreatment, setSavingTreatment] = useState(false);
const [patientForm, setPatientForm] = useState<CreatePatientInput>(EMPTY_PATIENT_FORM); const [patientForm, setPatientForm] = useState<CreatePatientInput>(EMPTY_PATIENT_FORM);
const [errorMessage, setErrorMessage] = useState<string>('');
const [successMessage, setSuccessMessage] = useState<string>('');
const canEditPatients = hasPermission(currentOrganization, 'TAB_PATIENTS_EDIT'); const canEditPatients = hasPermission(currentOrganization, 'TAB_PATIENTS_EDIT');
const sortedPatients = useMemo( const sortedPatients = useMemo(
@@ -58,21 +60,9 @@ export default function PatientsPage() {
void loadPatients(''); void loadPatients('');
}, []); }, []);
useEffect(() => {
if (!successMessage) {
return;
}
const timeout = setTimeout(() => {
setSuccessMessage('');
}, 3000);
return () => clearTimeout(timeout);
}, [successMessage]);
async function loadPatients(q: string) { async function loadPatients(q: string) {
setLoadingPatients(true); setLoadingPatients(true);
setErrorMessage(''); toast.setError('');
try { try {
const response = await patientsApi.list({ q, page: 1, limit: 25 }); const response = await patientsApi.list({ q, page: 1, limit: 25 });
const items = response.data.items; const items = response.data.items;
@@ -82,9 +72,8 @@ export default function PatientsPage() {
const freshSelected = items.find((item) => item.id === selectedPatient.id); const freshSelected = items.find((item) => item.id === selectedPatient.id);
setSelectedPatient(freshSelected); setSelectedPatient(freshSelected);
} }
} catch (error: any) { } catch (error: unknown) {
const message = Array.isArray(error?.message) ? error.message.join(', ') : error?.message; toast.showError(formatApiErrorMessage(error, 'Failed to load patients.'));
setErrorMessage(message || 'Failed to load patients.');
} finally { } finally {
setLoadingPatients(false); setLoadingPatients(false);
} }
@@ -92,13 +81,12 @@ export default function PatientsPage() {
async function loadTreatments(patientId: string) { async function loadTreatments(patientId: string) {
setLoadingTreatments(true); setLoadingTreatments(true);
setErrorMessage(''); toast.setError('');
try { try {
const response = await patientsApi.listTreatments(patientId); const response = await patientsApi.listTreatments(patientId);
setTreatments(response.data); setTreatments(response.data);
} catch (error: any) { } catch (error: unknown) {
const message = Array.isArray(error?.message) ? error.message.join(', ') : error?.message; toast.showError(formatApiErrorMessage(error, 'Failed to load treatment history.'));
setErrorMessage(message || 'Failed to load treatment history.');
} finally { } finally {
setLoadingTreatments(false); setLoadingTreatments(false);
} }
@@ -106,8 +94,7 @@ export default function PatientsPage() {
async function handleCreatePatient() { async function handleCreatePatient() {
setSavingPatient(true); setSavingPatient(true);
setErrorMessage(''); toast.setError('');
setSuccessMessage('');
try { try {
const response = await patientsApi.create(patientForm); const response = await patientsApi.create(patientForm);
setIsCreateOpen(false); setIsCreateOpen(false);
@@ -115,12 +102,11 @@ export default function PatientsPage() {
await loadPatients(search); await loadPatients(search);
setSelectedPatient(response.data); setSelectedPatient(response.data);
await loadTreatments(response.data.id); await loadTreatments(response.data.id);
setSuccessMessage( toast.showSuccess(
`Patient ${response.data.firstName} ${response.data.lastName} was saved successfully.`, `Patient ${response.data.firstName} ${response.data.lastName} was saved successfully.`,
); );
} catch (error: any) { } catch (error: unknown) {
const message = Array.isArray(error?.message) ? error.message.join(', ') : error?.message; toast.showError(formatApiErrorMessage(error, 'Failed to save patient.'));
setErrorMessage(message || 'Failed to save patient.');
} finally { } finally {
setSavingPatient(false); setSavingPatient(false);
} }
@@ -139,29 +125,29 @@ export default function PatientsPage() {
}; };
setSavingTreatment(true); setSavingTreatment(true);
setErrorMessage(''); toast.setError('');
setSuccessMessage('');
try { try {
await patientsApi.addTreatment(selectedPatient.id, payload); await patientsApi.addTreatment(selectedPatient.id, payload);
await loadTreatments(selectedPatient.id); await loadTreatments(selectedPatient.id);
setSuccessMessage('Treatment entry added successfully.'); toast.showSuccess('Treatment entry added successfully.');
} catch (error: any) { } catch (error: unknown) {
const message = Array.isArray(error?.message) ? error.message.join(', ') : error?.message; toast.showError(formatApiErrorMessage(error, 'Failed to add treatment entry.'));
setErrorMessage(message || 'Failed to add treatment entry.');
} finally { } finally {
setSavingTreatment(false); setSavingTreatment(false);
} }
} }
return ( return (
<div className="relative space-y-6 pb-20"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between gap-3">
<h1 className="text-2xl font-semibold text-text-primary">Patients</h1> <h1 className="text-2xl font-semibold text-text-primary">Patients</h1>
<Button <Button
variant="primary" variant="primary"
disabled={!canEditPatients} disabled={!canEditPatients}
onClick={() => { onClick={() => {
if (!canEditPatients) return; if (!canEditPatients) return;
toast.clear();
setPatientForm(EMPTY_PATIENT_FORM);
setIsCreateOpen(true); setIsCreateOpen(true);
}} }}
title={!canEditPatients ? 'Read-only access for this organization.' : undefined} title={!canEditPatients ? 'Read-only access for this organization.' : undefined}
@@ -170,14 +156,21 @@ export default function PatientsPage() {
</Button> </Button>
</div> </div>
<CreatePatientModal <ToastStack {...toast.messages} />
isOpen={isCreateOpen}
formData={patientForm} {isCreateOpen && (
onChange={(patch) => setPatientForm((prev) => ({ ...prev, ...patch }))} <CreatePatientModal
onSubmit={handleCreatePatient} isOpen={isCreateOpen}
onClose={() => setIsCreateOpen(false)} formData={patientForm}
loading={savingPatient} 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="grid grid-cols-1 xl:grid-cols-3 gap-6">
<div className="xl:col-span-1"> <div className="xl:col-span-1">
@@ -213,21 +206,6 @@ export default function PatientsPage() {
<TreatmentHistoryPreview items={treatments} loading={loadingTreatments} /> <TreatmentHistoryPreview items={treatments} loading={loadingTreatments} />
</div> </div>
</div> </div>
{(errorMessage || successMessage) && (
<div className="absolute bottom-0 left-0 right-0 z-10 w-full">
{errorMessage && (
<div className="rounded-[var(--radius-sm)] border border-red-500/50 bg-red-500/10 px-3 py-2 text-sm text-red-300 shadow-lg">
{errorMessage}
</div>
)}
{successMessage && (
<div className="rounded-[var(--radius-sm)] border border-emerald-500/50 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-300 shadow-lg">
{successMessage}
</div>
)}
</div>
)}
</div> </div>
); );
} }

View File

@@ -5,8 +5,8 @@ import Link from 'next/link';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useAuth } from '@/lib/hooks/useAuth'; import { useAuth } from '@/lib/hooks/useAuth';
import { authApi } from '@/lib/api/auth'; import { authApi } from '@/lib/api/auth';
import { Button } from '@/components/ui/common/Button'; import { Button } from '@/components/ui/shared/Button';
import { Toast } from '@/components/ui/common/Toast'; import { Toast } from '@/components/ui/shared/Toast';
import type { SubscriptionAlertData } from '@/types/subscription'; import type { SubscriptionAlertData } from '@/types/subscription';
const PLAN_OPTIONS = [ const PLAN_OPTIONS = [

View File

@@ -6,7 +6,7 @@ import {
firstAccessibleDashboardPath, firstAccessibleDashboardPath,
canEditStaff, canEditStaff,
canViewStaff, canViewStaff,
} from '@/shared/permissions'; } from '@/components/shared/permissions';
import { import {
STAFF_FEATURE_GROUPS, STAFF_FEATURE_GROUPS,
permissionNamesFromFeatureState, permissionNamesFromFeatureState,
@@ -16,16 +16,18 @@ import {
formatAccessSummary, formatAccessSummary,
type FeaturePermState, type FeaturePermState,
} from '../../../components/staff/staff-permission-form'; } from '../../../components/staff/staff-permission-form';
import { Pencil, Trash2, Copy, Check, X } from 'lucide-react'; import { Pencil, Trash2, Copy, Check, X, UserX, UserCheck } from 'lucide-react';
import { DialogCloseButton } from '@/components/ui/common/DialogCloseButton'; import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
import { useAuth } from '@/lib/hooks/useAuth'; import { useAuth } from '@/lib/hooks/useAuth';
import { staffApi, type StaffMemberDto } from '@/lib/api/staff'; import { staffApi, type StaffMemberDto } from '@/lib/api/staff';
import { Button } from '@/components/ui/common/Button'; import { Button } from '@/components/ui/shared/Button';
import { Badge } from '@/components/ui/common/Badge'; import { Badge } from '@/components/ui/shared/Badge';
import { Input } from '@/components/ui/common/Input'; import { Input } from '@/components/ui/shared/Input';
import { Checkbox } from '@/components/ui/common/Checkbox'; import { Checkbox } from '@/components/ui/shared/Checkbox';
import { Table } from '@/components/ui/common/Table'; import { Table } from '@/components/ui/shared/Table';
import type { ApiError } from '@/types/api'; import { ToastStack } from '@/components/ui/shared/Toast';
import { formatApiErrorMessage } from '@/components/shared/formatApiError';
import { useToast } from '@/lib/hooks/useToast';
type StoredInviteLink = { type StoredInviteLink = {
membershipId: string; membershipId: string;
@@ -54,16 +56,19 @@ function writeStoredInviteLinks(orgId: string, links: Record<string, StoredInvit
window.localStorage.setItem(inviteLinksStorageKey(orgId), JSON.stringify(links)); window.localStorage.setItem(inviteLinksStorageKey(orgId), JSON.stringify(links));
} }
function formatApiMessage(err: unknown): string { function canShareStaffInviteLink(member: StaffMemberDto): boolean {
if (!err || typeof err !== 'object') return 'Something went wrong'; return (
const m = (err as ApiError).message; !member.isOwner &&
if (Array.isArray(m)) return m.join(', '); (member.invitationStatus === 'PENDING' || member.invitationStatus === 'EXPIRED')
if (typeof m === 'string') return m; );
return 'Something went wrong';
} }
function canShareStaffInviteLink(member: StaffMemberDto): boolean { function canDisableStaff(member: StaffMemberDto): boolean {
return !member.isOwner && member.invitationStatus !== 'ACTIVE'; return !member.isOwner && member.isActive;
}
function canEnableStaff(member: StaffMemberDto): boolean {
return !member.isOwner && member.invitationStatus === 'DISABLED';
} }
function PermissionGrid({ function PermissionGrid({
@@ -136,8 +141,7 @@ export default function StaffPage() {
unlimited: boolean; unlimited: boolean;
} | null>(null); } | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const toast = useToast();
const [success, setSuccess] = useState('');
const [inviteOpen, setInviteOpen] = useState(false); const [inviteOpen, setInviteOpen] = useState(false);
const [inviteEmail, setInviteEmail] = useState(''); const [inviteEmail, setInviteEmail] = useState('');
@@ -159,6 +163,10 @@ export default function StaffPage() {
const [editName, setEditName] = useState(''); const [editName, setEditName] = useState('');
const [editPerms, setEditPerms] = useState(() => emptyFeaturePermissionState()); const [editPerms, setEditPerms] = useState(() => emptyFeaturePermissionState());
const [editLoading, setEditLoading] = useState(false); const [editLoading, setEditLoading] = useState(false);
const [disableTarget, setDisableTarget] = useState<StaffMemberDto | null>(null);
const [disablingMembershipId, setDisablingMembershipId] = useState<string | null>(null);
const [enableTarget, setEnableTarget] = useState<StaffMemberDto | null>(null);
const [enablingMembershipId, setEnablingMembershipId] = useState<string | null>(null);
const canEdit = useMemo(() => canEditStaff(currentOrganization), [currentOrganization]); const canEdit = useMemo(() => canEditStaff(currentOrganization), [currentOrganization]);
const hasActivePlan = Boolean(currentOrganization?.plan); const hasActivePlan = Boolean(currentOrganization?.plan);
@@ -168,15 +176,21 @@ export default function StaffPage() {
return seats.used >= seats.limit; return seats.used >= seats.limit;
}, [seats]); }, [seats]);
const hasAvailableSeat = useMemo(() => {
if (!seats || seats.unlimited) return true;
if (seats.limit == null) return true;
return seats.used < seats.limit;
}, [seats]);
const load = useCallback(async () => { const load = useCallback(async () => {
setError(''); toast.setError('');
setLoading(true); setLoading(true);
try { try {
const res = await staffApi.list(); const res = await staffApi.list();
setMembers(res.data.members); setMembers(res.data.members);
setSeats(res.data.seats); setSeats(res.data.seats);
} catch (e) { } catch (e) {
setError(formatApiMessage(e)); toast.showError(formatApiErrorMessage(e, 'Failed to load staff.'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -221,17 +235,11 @@ export default function StaffPage() {
} }
}, [currentOrganization, router]); }, [currentOrganization, router]);
useEffect(() => {
if (!success) return;
const t = setTimeout(() => setSuccess(''), 4000);
return () => clearTimeout(t);
}, [success]);
async function copyStaffInviteLink(member: StaffMemberDto) { async function copyStaffInviteLink(member: StaffMemberDto) {
if (!canShareStaffInviteLink(member)) return; if (!canShareStaffInviteLink(member)) return;
setCopyingInviteMembershipId(member.id); setCopyingInviteMembershipId(member.id);
setError(''); toast.setError('');
try { try {
let invitationUrl = pendingInviteLinks[member.id]?.invitationUrl; let invitationUrl = pendingInviteLinks[member.id]?.invitationUrl;
if (!invitationUrl || member.invitationStatus === 'EXPIRED') { if (!invitationUrl || member.invitationStatus === 'EXPIRED') {
@@ -257,7 +265,7 @@ export default function StaffPage() {
await load(); await load();
} }
} catch (e) { } catch (e) {
setError(formatApiMessage(e)); toast.showError(formatApiErrorMessage(e, 'Could not copy invitation link.'));
} finally { } finally {
setCopyingInviteMembershipId(null); setCopyingInviteMembershipId(null);
} }
@@ -265,7 +273,7 @@ export default function StaffPage() {
async function submitInvite() { async function submitInvite() {
setInviteLoading(true); setInviteLoading(true);
setError(''); toast.setError('');
setLastInviteInfo(null); setLastInviteInfo(null);
const displayName = inviteName.trim(); const displayName = inviteName.trim();
const displayEmail = inviteEmail.trim(); const displayEmail = inviteEmail.trim();
@@ -295,14 +303,13 @@ export default function StaffPage() {
setPendingInviteLinks(nextLinks); setPendingInviteLinks(nextLinks);
writeStoredInviteLinks(currentOrganization.id, nextLinks); writeStoredInviteLinks(currentOrganization.id, nextLinks);
} }
setSuccess('');
setInviteOpen(false); setInviteOpen(false);
setInviteEmail(''); setInviteEmail('');
setInviteName(''); setInviteName('');
setInvitePerms(emptyFeaturePermissionState()); setInvitePerms(emptyFeaturePermissionState());
await load(); await load();
} catch (e) { } catch (e) {
setError(formatApiMessage(e)); toast.showError(formatApiErrorMessage(e, 'Failed to send invitation.'));
} finally { } finally {
setInviteLoading(false); setInviteLoading(false);
} }
@@ -320,36 +327,57 @@ export default function StaffPage() {
async function submitEdit() { async function submitEdit() {
if (!editing) return; if (!editing) return;
setEditLoading(true); setEditLoading(true);
setError(''); toast.setError('');
try { try {
await staffApi.updateMember(editing.id, { await staffApi.updateMember(editing.id, {
name: editName.trim(), name: editName.trim(),
permissionNames: permissionNamesFromFeatureState(editPerms), permissionNames: permissionNamesFromFeatureState(editPerms),
}); });
setSuccess('Member updated'); toast.showSuccess('Member updated.');
setEditing(null); setEditing(null);
await load(); await load();
} catch (e) { } catch (e) {
setError(formatApiMessage(e)); toast.showError(formatApiErrorMessage(e, 'Failed to update member.'));
} finally { } finally {
setEditLoading(false); setEditLoading(false);
} }
} }
async function removeMember(m: StaffMemberDto) { function handleDeleteMember() {
if (m.isOwner) return; toast.showError('Delete is not implemented yet.');
if (m.userId === user?.id) { }
if (!confirm('Remove yourself from this organization? You will lose access.')) return;
} else { async function confirmDisableMember() {
if (!confirm(`Remove ${m.name} from this organization?`)) return; if (!disableTarget || !canDisableStaff(disableTarget)) return;
}
setError(''); setDisablingMembershipId(disableTarget.id);
toast.setError('');
try { try {
await staffApi.removeMember(m.id); await staffApi.disableMember(disableTarget.id);
setSuccess('Member removed'); toast.showSuccess(`${disableTarget.name} was disabled. A seat is now available.`);
setDisableTarget(null);
await load(); await load();
} catch (e) { } catch (e) {
setError(formatApiMessage(e)); toast.showError(formatApiErrorMessage(e, 'Failed to disable member.'));
} finally {
setDisablingMembershipId(null);
}
}
async function confirmEnableMember() {
if (!enableTarget || !canEnableStaff(enableTarget) || !hasAvailableSeat) return;
setEnablingMembershipId(enableTarget.id);
toast.setError('');
try {
await staffApi.enableMember(enableTarget.id);
toast.showSuccess(`${enableTarget.name} was enabled and can sign in again.`);
setEnableTarget(null);
await load();
} catch (e) {
toast.showError(formatApiErrorMessage(e, 'Failed to enable member.'));
} finally {
setEnablingMembershipId(null);
} }
} }
@@ -383,6 +411,8 @@ export default function StaffPage() {
</Button> </Button>
</div> </div>
<ToastStack {...toast.messages} />
{seats && ( {seats && (
<p className="text-sm text-text-secondary"> <p className="text-sm text-text-secondary">
Seats:{' '} Seats:{' '}
@@ -400,18 +430,6 @@ export default function StaffPage() {
</p> </p>
)} )}
{error && (
<div className="rounded-[var(--radius-md)] border border-red-500/40 bg-red-500/10 px-4 py-3 text-sm text-red-700 dark:text-red-300">
{error}
</div>
)}
{success && (
<div className="rounded-[var(--radius-md)] border border-primary/30 bg-primary-soft/40 px-4 py-3 text-sm text-text-primary">
{success}
</div>
)}
{lastInviteInfo && ( {lastInviteInfo && (
<div className="relative rounded-[var(--radius-md)] border border-border-strong bg-background-secondary/90 px-4 py-3 pr-12 shadow-[inset_0_1px_0_rgba(255,255,255,0.04)] space-y-3"> <div className="relative rounded-[var(--radius-md)] border border-border-strong bg-background-secondary/90 px-4 py-3 pr-12 shadow-[inset_0_1px_0_rgba(255,255,255,0.04)] space-y-3">
<button <button
@@ -453,7 +471,7 @@ export default function StaffPage() {
} }
void (async () => { void (async () => {
setCopyingInviteMembershipId(lastInviteInfo.membershipId); setCopyingInviteMembershipId(lastInviteInfo.membershipId);
setError(''); toast.setError('');
try { try {
const res = await staffApi.getInvitationLink(lastInviteInfo.membershipId); const res = await staffApi.getInvitationLink(lastInviteInfo.membershipId);
if (currentOrganization?.id) { if (currentOrganization?.id) {
@@ -473,7 +491,7 @@ export default function StaffPage() {
setCopiedInviteMembershipId(lastInviteInfo.membershipId); setCopiedInviteMembershipId(lastInviteInfo.membershipId);
setTimeout(() => setCopiedInviteMembershipId(null), 1500); setTimeout(() => setCopiedInviteMembershipId(null), 1500);
} catch (e) { } catch (e) {
setError(formatApiMessage(e)); toast.showError(formatApiErrorMessage(e, 'Could not copy invitation link.'));
} finally { } finally {
setCopyingInviteMembershipId(null); setCopyingInviteMembershipId(null);
} }
@@ -501,7 +519,9 @@ export default function StaffPage() {
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">Role</th> <th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">Role</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-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">Access</th> <th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">Access</th>
<th className="px-6 py-3 text-right text-xs font-medium text-text-muted uppercase tracking-wider w-28">Actions</th> <th className="px-6 py-3 text-center text-xs font-medium text-text-muted uppercase tracking-wider w-36">
Action
</th>
</tr> </tr>
} }
body={ body={
@@ -522,6 +542,8 @@ export default function StaffPage() {
<Badge variant="success">Active</Badge> <Badge variant="success">Active</Badge>
) : m.invitationStatus === 'PENDING' ? ( ) : m.invitationStatus === 'PENDING' ? (
<Badge variant="warning">Pending</Badge> <Badge variant="warning">Pending</Badge>
) : m.invitationStatus === 'DISABLED' ? (
<Badge variant="default">Disabled</Badge>
) : ( ) : (
<Badge variant="danger">Expired</Badge> <Badge variant="danger">Expired</Badge>
)} )}
@@ -535,9 +557,9 @@ export default function StaffPage() {
</span> </span>
)} )}
</td> </td>
<td className="px-6 py-1.5 align-middle"> <td className="px-6 py-1.5 align-middle text-center">
{!m.isOwner && ( {!m.isOwner && (
<div className="flex min-h-[36px] items-center justify-end gap-1"> <div className="flex min-h-[36px] items-center justify-center gap-1 mx-auto w-fit">
{canShareStaffInviteLink(m) && ( {canShareStaffInviteLink(m) && (
<button <button
type="button" type="button"
@@ -554,6 +576,44 @@ export default function StaffPage() {
)} )}
</button> </button>
)} )}
{canEnableStaff(m) && (
<button
type="button"
className={`p-2 rounded-md ${
canEdit
? 'text-text-secondary hover:bg-background-card/80 hover:text-primary'
: 'text-text-muted opacity-50 cursor-not-allowed'
}`}
aria-label="Enable member"
disabled={!canEdit || enablingMembershipId === m.id}
title="Enable member (uses a seat)"
onClick={() => {
if (!canEdit) return;
setEnableTarget(m);
}}
>
<UserCheck className="w-4 h-4" />
</button>
)}
{canDisableStaff(m) && (
<button
type="button"
className={`p-2 rounded-md ${
canEdit
? 'text-text-secondary hover:bg-background-card/80 hover:text-amber-600'
: 'text-text-muted opacity-50 cursor-not-allowed'
}`}
aria-label="Disable member"
disabled={!canEdit || disablingMembershipId === m.id}
title="Disable member (frees a seat)"
onClick={() => {
if (!canEdit) return;
setDisableTarget(m);
}}
>
<UserX className="w-4 h-4" />
</button>
)}
<button <button
type="button" type="button"
className={`p-2 rounded-md ${ className={`p-2 rounded-md ${
@@ -577,11 +637,12 @@ export default function StaffPage() {
? 'text-text-secondary hover:bg-red-500/15 hover:text-red-600' ? 'text-text-secondary hover:bg-red-500/15 hover:text-red-600'
: 'text-text-muted opacity-50 cursor-not-allowed' : 'text-text-muted opacity-50 cursor-not-allowed'
}`} }`}
aria-label="Remove member" aria-label="Delete member"
disabled={!canEdit} disabled={!canEdit}
title="Delete member (not implemented)"
onClick={() => { onClick={() => {
if (!canEdit) return; if (!canEdit) return;
void removeMember(m); handleDeleteMember();
}} }}
> >
<Trash2 className="w-4 h-4" /> <Trash2 className="w-4 h-4" />
@@ -647,6 +708,120 @@ export default function StaffPage() {
</div> </div>
)} )}
{enableTarget && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/55">
<div
className="surface-card w-full max-w-md p-5 space-y-4 shadow-xl"
role="dialog"
aria-modal="true"
aria-labelledby="enable-staff-title"
>
<div className="flex items-start justify-between gap-2">
<h2 id="enable-staff-title" className="text-lg font-semibold text-text-primary pr-2">
Enable team member
</h2>
<DialogCloseButton
onClick={() => {
if (enablingMembershipId) return;
setEnableTarget(null);
}}
/>
</div>
<p className="text-sm text-text-secondary">
Enable <span className="font-medium text-text-primary">{enableTarget.name}</span> (
{enableTarget.email})?
</p>
<ul className="text-sm text-text-secondary space-y-2 list-disc pl-5">
<li>They can sign in to this organization again with their existing account.</li>
<li>No new invitation is sent and no data was removed while they were disabled.</li>
<li>
Enabling uses <span className="text-text-primary font-medium">one seat</span> on your
plan.
</li>
</ul>
{!hasAvailableSeat && (
<p className="text-sm text-amber-600 dark:text-amber-400">
No seats are available. Disable another member or upgrade your plan before enabling
this person.
</p>
)}
<div className="flex justify-end gap-2 pt-1">
<Button
type="button"
variant="outline"
disabled={Boolean(enablingMembershipId)}
onClick={() => setEnableTarget(null)}
>
Cancel
</Button>
<Button
type="button"
variant="primary"
isLoading={enablingMembershipId === enableTarget.id}
disabled={Boolean(enablingMembershipId) || !hasAvailableSeat}
onClick={() => void confirmEnableMember()}
>
Enable member
</Button>
</div>
</div>
</div>
)}
{disableTarget && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/55">
<div
className="surface-card w-full max-w-md p-5 space-y-4 shadow-xl"
role="dialog"
aria-modal="true"
aria-labelledby="disable-staff-title"
>
<div className="flex items-start justify-between gap-2">
<h2 id="disable-staff-title" className="text-lg font-semibold text-text-primary pr-2">
Disable team member
</h2>
<DialogCloseButton
onClick={() => {
if (disablingMembershipId) return;
setDisableTarget(null);
}}
/>
</div>
<p className="text-sm text-text-secondary">
Disable <span className="font-medium text-text-primary">{disableTarget.name}</span> (
{disableTarget.email})?
</p>
<ul className="text-sm text-text-secondary space-y-2 list-disc pl-5">
<li>They will not be able to sign in to this organization.</li>
<li>No data will be removed.</li>
<li>
Disabling frees <span className="text-text-primary font-medium">one seat</span> on your
plan so you can invite someone else.
</li>
</ul>
<div className="flex justify-end gap-2 pt-1">
<Button
type="button"
variant="outline"
disabled={Boolean(disablingMembershipId)}
onClick={() => setDisableTarget(null)}
>
Cancel
</Button>
<Button
type="button"
variant="danger"
isLoading={disablingMembershipId === disableTarget.id}
disabled={Boolean(disablingMembershipId)}
onClick={() => void confirmDisableMember()}
>
Disable member
</Button>
</div>
</div>
</div>
)}
{editing && ( {editing && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50"> <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
<div <div

View File

@@ -2,7 +2,7 @@
import Link from 'next/link'; import Link from 'next/link';
import { useAuth } from '@/lib/hooks/useAuth'; import { useAuth } from '@/lib/hooks/useAuth';
import { Card } from '@/components/ui/common/Card'; import { Card } from '@/components/ui/shared/Card';
export default function TodayPage() { export default function TodayPage() {
const { currentOrganization } = useAuth(); const { currentOrganization } = useAuth();

View File

@@ -4,8 +4,8 @@ import { useEffect, useMemo, useState } from 'react';
import { Suspense } from 'react'; import { Suspense } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { Button } from '@/components/ui/common/Button'; import { Button } from '@/components/ui/shared/Button';
import { Input } from '@/components/ui/common/Input'; import { Input } from '@/components/ui/shared/Input';
import { staffApi } from '@/lib/api/staff'; import { staffApi } from '@/lib/api/staff';
function AcceptInviteContent() { function AcceptInviteContent() {

View File

@@ -8,8 +8,8 @@ import type { OrganizationDetailsFormValues } from '@/components/ui/auth/Organiz
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod'; import * as z from 'zod';
import { Lock, Mail, User } from 'lucide-react'; import { Lock, Mail, User } from 'lucide-react';
import { Button } from '@/components/ui/common/Button'; import { Button } from '@/components/ui/shared/Button';
import { Input } from '@/components/ui/common/Input'; import { Input } from '@/components/ui/shared/Input';
import { OrganizationDetailsFields } from '@/components/ui/auth/OrganizationDetailsFields'; import { OrganizationDetailsFields } from '@/components/ui/auth/OrganizationDetailsFields';
import { RegistrationProgressSteps } from '@/components/ui/auth/RegistrationProgressSteps'; import { RegistrationProgressSteps } from '@/components/ui/auth/RegistrationProgressSteps';
import { organizationApi } from '@/lib/api/organization'; import { organizationApi } from '@/lib/api/organization';

View File

@@ -116,8 +116,8 @@ import Link from 'next/link';
import { Mail, Lock } from 'lucide-react'; import { Mail, Lock } from 'lucide-react';
import { useAuth } from '@/lib/hooks/useAuth'; import { useAuth } from '@/lib/hooks/useAuth';
import { Button } from '@/components/ui/common/Button'; import { Button } from '@/components/ui/shared/Button';
import { Input } from '@/components/ui/common/Input'; import { Input } from '@/components/ui/shared/Input';
const loginSchema = z.object({ const loginSchema = z.object({
email: z.string().email('Please enter a valid email address'), email: z.string().email('Please enter a valid email address'),

View File

@@ -2,8 +2,8 @@
import Link from 'next/link'; import Link from 'next/link';
import { useAuth } from '@/lib/hooks/useAuth'; import { useAuth } from '@/lib/hooks/useAuth';
import { Button } from '@/components/ui/common/Button'; import { Button } from '@/components/ui/shared/Button';
import { ThemeToggle } from '@/components/ui/common/ThemeToggle'; import { ThemeToggle } from '@/components/ui/shared/ThemeToggle';
import { Building2, Beaker, Calendar, Shield, Clock, Users } from 'lucide-react'; import { Building2, Beaker, Calendar, Shield, Clock, Users } from 'lucide-react';
export default function HomePage() { export default function HomePage() {

View File

@@ -9,8 +9,8 @@ import { Mail, Lock, User } from 'lucide-react';
import { useAuth } from '@/lib/hooks/useAuth'; import { useAuth } from '@/lib/hooks/useAuth';
import { OrganizationDetailsFields } from '@/components/ui/auth/OrganizationDetailsFields'; import { OrganizationDetailsFields } from '@/components/ui/auth/OrganizationDetailsFields';
import { RegistrationProgressSteps } from '@/components/ui/auth/RegistrationProgressSteps'; import { RegistrationProgressSteps } from '@/components/ui/auth/RegistrationProgressSteps';
import { Button } from '@/components/ui/common/Button'; import { Button } from '@/components/ui/shared/Button';
import { Input } from '@/components/ui/common/Input'; import { Input } from '@/components/ui/shared/Input';
const registerSchema = z.object({ const registerSchema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters'), name: z.string().min(2, 'Name must be at least 2 characters'),
email: z.string().email('Please enter a valid email address'), email: z.string().email('Please enter a valid email address'),

View File

@@ -0,0 +1,16 @@
import { Building2, Beaker, type LucideIcon } from 'lucide-react';
import type { Organization } from '@/types/organization';
/** Clinic → Building2, Lab → Beaker (switch-organization cards). */
export function organizationTypeIcon(type: Organization['type']): LucideIcon {
return type === 'CLINIC' ? Building2 : Beaker;
}
/** Organizations tab lists counterpart orgs (labs for clinics, clinics for labs). */
export function counterpartOrganizationType(
currentType: Organization['type'] | undefined,
): Organization['type'] {
if (currentType === 'CLINIC') return 'LAB';
if (currentType === 'LAB') return 'CLINIC';
return 'CLINIC';
}

View File

@@ -16,6 +16,11 @@ export function hasPermission(org: Organization | null, permission: string): boo
return Boolean(org.permissions?.includes(permission)); return Boolean(org.permissions?.includes(permission));
} }
/** True when the user is owner of the currently selected organization. */
export function canCreateOrganizationFromCurrentOrg(org: Organization | null): boolean {
return Boolean(org?.isOwner);
}
/** Sidebar / route guard: READ access to a tab */ /** Sidebar / route guard: READ access to a tab */
export function canViewTab(org: Organization | null, readPermission: string): boolean { export function canViewTab(org: Organization | null, readPermission: string): boolean {
return hasPermission(org, readPermission); return hasPermission(org, readPermission);

View File

@@ -1,4 +1,4 @@
import { isSameLocalCalendarDay } from '@/lib/appointmentTime'; import { isSameLocalCalendarDay } from '@/components/appointments/appointmentTime';
import type { TreatmentAppointment } from '@/types/treatment'; import type { TreatmentAppointment } from '@/types/treatment';
/** /**

View File

@@ -1,9 +1,9 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Button } from '@/components/ui/common/Button'; import { Button } from '@/components/ui/shared/Button';
import { DialogCloseButton } from '@/components/ui/common/DialogCloseButton'; import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
import { Dropdown } from '@/components/ui/common/Dropdown'; import { Dropdown } from '@/components/ui/shared/Dropdown';
import type { AppointmentPurpose, AppointmentRecord } from '@/types/appointment'; import type { AppointmentPurpose, AppointmentRecord } from '@/types/appointment';
import { APPOINTMENT_PURPOSE_LABEL } from '@/components/ui/appointments/appointmentPurposeStyles'; import { APPOINTMENT_PURPOSE_LABEL } from '@/components/ui/appointments/appointmentPurposeStyles';
import type { Patient } from '@/types/patient'; import type { Patient } from '@/types/patient';
@@ -12,7 +12,7 @@ import {
compareLocalDayStart, compareLocalDayStart,
formatTimeForInput, formatTimeForInput,
isSameLocalCalendarDay, isSameLocalCalendarDay,
} from '@/lib/appointmentTime'; } from '@/components/appointments/appointmentTime';
interface AppointmentBookingModalProps { interface AppointmentBookingModalProps {
open: boolean; open: boolean;

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { DialogCloseButton } from '@/components/ui/common/DialogCloseButton'; import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
import { import {
APPOINTMENT_PURPOSE_LABEL, APPOINTMENT_PURPOSE_LABEL,
purposeStyle, purposeStyle,

View File

@@ -2,12 +2,12 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import type { AppointmentColumnProvider, AppointmentRecord } from '@/types/appointment'; import type { AppointmentColumnProvider, AppointmentRecord } from '@/types/appointment';
import { formatHourLabel } from '@/lib/appointmentTime'; import { formatHourLabel } from '@/components/appointments/appointmentTime';
import { import {
computeAppointmentLaneLayouts, computeAppointmentLaneLayouts,
findOverlapCluster, findOverlapCluster,
lanePositionStyles, lanePositionStyles,
} from '@/lib/appointmentOverlapLayout'; } from '@/components/appointments/appointmentOverlapLayout';
import { purposeStyle } from '@/components/ui/appointments/appointmentPurposeStyles'; import { purposeStyle } from '@/components/ui/appointments/appointmentPurposeStyles';
import { AppointmentOverlapPopover } from '@/components/ui/appointments/AppointmentOverlapPopover'; import { AppointmentOverlapPopover } from '@/components/ui/appointments/AppointmentOverlapPopover';

View File

@@ -1,8 +1,8 @@
'use client'; 'use client';
import { Search } from 'lucide-react'; import { Search } from 'lucide-react';
import { Button } from '@/components/ui/common/Button'; import { Button } from '@/components/ui/shared/Button';
import { Input } from '@/components/ui/common/Input'; import { Input } from '@/components/ui/shared/Input';
import type { Patient } from '@/types/patient'; import type { Patient } from '@/types/patient';
interface AppointmentsPatientSearchProps { interface AppointmentsPatientSearchProps {
@@ -49,7 +49,7 @@ export function AppointmentsPatientSearch({
onClick={onAddPatient} onClick={onAddPatient}
title={!canAddPatient ? 'You do not have permission to add patients.' : undefined} title={!canAddPatient ? 'You do not have permission to add patients.' : undefined}
> >
+ Add New Patient New Patient
</Button> </Button>
)} )}
</div> </div>

View File

@@ -2,7 +2,7 @@
import { Building2, Mail } from 'lucide-react'; import { Building2, Mail } from 'lucide-react';
import type { FieldErrors, UseFormRegister, UseFormSetValue } from 'react-hook-form'; import type { FieldErrors, UseFormRegister, UseFormSetValue } from 'react-hook-form';
import { Input } from '@/components/ui/common/Input'; import { Input } from '@/components/ui/shared/Input';
export type OrganizationDetailsFormValues = { export type OrganizationDetailsFormValues = {
organizationName: string; organizationName: string;

View File

@@ -1,10 +1,10 @@
'use client'; 'use client';
import { DialogCloseButton } from '@/components/ui/common/DialogCloseButton'; import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
import { ToastStack, type ToastMessages } from '@/components/ui/common/Toast'; import { ToastStack, type ToastMessages } from '@/components/ui/shared/Toast';
import type { OrganizationInvitationHistoryItemDto } from '@/lib/api/organization'; import type { OrganizationInvitationHistoryItemDto } from '@/lib/api/organization';
import { Badge, organizationConnectionStatusVariant } from '@/components/ui/common/Badge'; import { Badge, organizationConnectionStatusVariant } from '@/components/ui/shared/Badge';
import { Table } from '@/components/ui/common/Table'; import { Table } from '@/components/ui/shared/Table';
import { CopyInvitationLinkButton } from '@/components/ui/organizations/CopyInvitationLinkButton'; import { CopyInvitationLinkButton } from '@/components/ui/organizations/CopyInvitationLinkButton';
function formatInvitationStatusLabel(status: OrganizationInvitationHistoryItemDto['status']): string { function formatInvitationStatusLabel(status: OrganizationInvitationHistoryItemDto['status']): string {

View File

@@ -1,13 +1,26 @@
'use client'; 'use client';
import { useState } from 'react'; import { useMemo, useState } from 'react';
import { useAuth } from '@/lib/hooks/useAuth'; import { useAuth } from '@/lib/hooks/useAuth';
import { canCreateOrganizationFromCurrentOrg } from '@/components/shared/permissions';
import { Building2, Beaker, Mail } from 'lucide-react'; import { Building2, Beaker, Mail } from 'lucide-react';
import { Input } from '@/components/ui/common/Input'; import { Input } from '@/components/ui/shared/Input';
import { Button } from '@/components/ui/common/Button'; import { Button } from '@/components/ui/shared/Button';
export function OrganizationSelectorContent() { export function OrganizationSelectorContent() {
const { organizations, selectOrganization, createOrganization, isLoading, error, clearError } = useAuth(); const {
organizations,
currentOrganization,
selectOrganization,
createOrganization,
isLoading,
error,
clearError,
} = useAuth();
const canCreateOrganization = useMemo(
() => canCreateOrganizationFromCurrentOrg(currentOrganization),
[currentOrganization],
);
const [isCreateOpen, setIsCreateOpen] = useState(false); const [isCreateOpen, setIsCreateOpen] = useState(false);
const [organizationName, setOrganizationName] = useState(''); const [organizationName, setOrganizationName] = useState('');
const [organizationEmail, setOrganizationEmail] = useState(''); const [organizationEmail, setOrganizationEmail] = useState('');
@@ -44,22 +57,26 @@ export function OrganizationSelectorContent() {
<div> <div>
<h1 className="text-3xl font-semibold text-text-primary">Organizations</h1> <h1 className="text-3xl font-semibold text-text-primary">Organizations</h1>
<p className="text-text-secondary mt-2"> <p className="text-text-secondary mt-2">
Select an organization to continue, or create a new one. {canCreateOrganization
? 'Select an organization to continue, or create a new one.'
: 'Select an organization to continue.'}
</p> </p>
</div> </div>
<Button {canCreateOrganization && (
type="button" <Button
variant={isCreateOpen ? 'outline' : 'primary'} type="button"
onClick={() => { variant={isCreateOpen ? 'outline' : 'primary'}
clearError(); onClick={() => {
setIsCreateOpen((prev) => !prev); clearError();
}} setIsCreateOpen((prev) => !prev);
> }}
{isCreateOpen ? 'Cancel' : 'Create Organization'} >
</Button> {isCreateOpen ? 'Cancel' : 'Create Organization'}
</Button>
)}
</div> </div>
{isCreateOpen && ( {canCreateOrganization && isCreateOpen && (
<div className="surface-card p-6 space-y-4"> <div className="surface-card p-6 space-y-4">
<Input <Input
label="Organization name" label="Organization name"
@@ -126,7 +143,11 @@ export function OrganizationSelectorContent() {
{!organizations.length ? ( {!organizations.length ? (
<div className="surface-card p-8 text-center"> <div className="surface-card p-8 text-center">
<p className="text-text-secondary">No organizations found. Create your first one to continue.</p> <p className="text-text-secondary">
{canCreateOrganization
? 'No organizations found. Create your first one to continue.'
: 'No organizations found. Ask an organization owner to invite you.'}
</p>
</div> </div>
) : ( ) : (
<div className="grid gap-4"> <div className="grid gap-4">

View File

@@ -1,7 +1,8 @@
'use client'; 'use client';
import { Button } from '@/components/ui/common/Button'; import { Button } from '@/components/ui/shared/Button';
import { Input } from '@/components/ui/common/Input'; import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
import { Input } from '@/components/ui/shared/Input';
import { CreatePatientInput } from '@/types/patient'; import { CreatePatientInput } from '@/types/patient';
interface CreatePatientModalProps { interface CreatePatientModalProps {
@@ -11,22 +12,27 @@ interface CreatePatientModalProps {
onSubmit: () => void; onSubmit: () => void;
onClose: () => void; onClose: () => void;
loading?: boolean; loading?: boolean;
/** Inline panel on Patients page; centered dialog on Appointments. */
variant?: 'inline' | 'dialog';
} }
export function CreatePatientModal({ function CreatePatientFormFields({
isOpen,
formData, formData,
onChange, onChange,
onSubmit, onSubmit,
onClose, onClose,
loading = false, loading,
}: CreatePatientModalProps) { showCancel,
if (!isOpen) { }: {
return null; formData: CreatePatientInput;
} onChange: (patch: Partial<CreatePatientInput>) => void;
onSubmit: () => void;
onClose: () => void;
loading: boolean;
showCancel: boolean;
}) {
return ( return (
<div className="surface-card p-4 space-y-3"> <>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3"> <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<Input <Input
label="First name" label="First name"
@@ -60,10 +66,80 @@ export function CreatePatientModal({
> >
Save Patient Save Patient
</Button> </Button>
<Button variant="ghost" onClick={onClose}> {showCancel && (
Cancel <Button variant="ghost" onClick={onClose}>
</Button> Cancel
</Button>
)}
</div> </div>
</>
);
}
export function CreatePatientModal({
isOpen,
formData,
onChange,
onSubmit,
onClose,
loading = false,
variant = 'inline',
}: CreatePatientModalProps) {
if (!isOpen) {
return null;
}
if (variant === 'dialog') {
return (
<div
className="fixed inset-0 z-[60] flex items-center justify-center p-4 bg-black/55"
role="presentation"
onMouseDown={(e) => {
if (e.target === e.currentTarget) {
onClose();
}
}}
>
<div
className="surface-card w-full max-w-[min(56rem,calc(100vw-17rem))] p-5 space-y-4 shadow-xl"
role="dialog"
aria-modal="true"
aria-labelledby="create-patient-dialog-title"
onMouseDown={(e) => e.stopPropagation()}
>
<div className="flex items-start justify-between gap-2">
<h2
id="create-patient-dialog-title"
className="text-lg font-semibold text-text-primary pr-2"
>
New patient
</h2>
<DialogCloseButton onClick={onClose} />
</div>
<CreatePatientFormFields
formData={formData}
onChange={onChange}
onSubmit={onSubmit}
onClose={onClose}
loading={loading}
showCancel={false}
/>
</div>
</div>
);
}
return (
<div className="surface-card p-4 space-y-3">
<CreatePatientFormFields
formData={formData}
onChange={onChange}
onSubmit={onSubmit}
onClose={onClose}
loading={loading}
showCancel
/>
</div> </div>
); );
} }

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import { Search } from 'lucide-react'; import { Search } from 'lucide-react';
import { Input } from '@/components/ui/common/Input'; import { Input } from '@/components/ui/shared/Input';
import { Patient } from '@/types/patient'; import { Patient } from '@/types/patient';
interface PatientSearchSelectProps { interface PatientSearchSelectProps {

View File

@@ -1,7 +1,8 @@
// src/components/ui/OrganizationCard.tsx // src/components/ui/OrganizationCard.tsx
import React from 'react'; import React from 'react';
import { Building2, Beaker, ChevronRight } from 'lucide-react'; import { ChevronRight } from 'lucide-react';
import type { Organization } from '@/types/organization'; import type { Organization } from '@/types/organization';
import { organizationTypeIcon } from '@/components/shared/organizationTypeIcon';
interface OrganizationCardProps { interface OrganizationCardProps {
organization: Organization; organization: Organization;
@@ -12,7 +13,7 @@ export const OrganizationCard: React.FC<OrganizationCardProps> = ({
organization, organization,
onSelect, onSelect,
}) => { }) => {
const Icon = organization.type === 'CLINIC' ? Building2 : Beaker; const Icon = organizationTypeIcon(organization.type);
const typeText = organization.type === 'CLINIC' ? 'Dental Clinic' : 'Dental Lab'; const typeText = organization.type === 'CLINIC' ? 'Dental Clinic' : 'Dental Lab';
return ( return (

View File

@@ -2,17 +2,11 @@
import { useEffect, useId, useRef, useState } from 'react'; import { useEffect, useId, useRef, useState } from 'react';
import { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react'; import { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react';
import { import { addCalendarDays, startOfLocalDay } from '@/components/appointments/appointmentTime';
addCalendarDays,
compareLocalDayStart,
startOfLocalDay,
} from '@/lib/appointmentTime';
interface ScheduleDayPickerProps { interface ScheduleDayPickerProps {
value: Date; value: Date;
onChange: (day: Date) => void; onChange: (day: Date) => void;
/** Optional lower bound for day selection and previous-day navigation. */
minDate?: Date;
label?: string; label?: string;
} }
@@ -39,27 +33,10 @@ function buildLocalDay(year: number, month: number, day: number): Date {
return new Date(year, month, day, 0, 0, 0, 0); return new Date(year, month, day, 0, 0, 0, 0);
} }
function clampToValidDay( function yearRange(anchor: Date): number[] {
year: number, const anchorYear = anchor.getFullYear();
month: number, const startYear = anchorYear - 10;
day: number, const endYear = anchorYear + 2;
min?: Date,
): Date {
const maxDay = daysInMonth(year, month);
let next = buildLocalDay(year, month, Math.min(Math.max(1, day), maxDay));
if (min) {
const floor = startOfLocalDay(min);
if (compareLocalDayStart(next, floor) < 0) {
next = floor;
}
}
return next;
}
function yearRange(min?: Date, anchor?: Date): number[] {
const now = new Date();
const startYear = min ? min.getFullYear() : now.getFullYear() - 5;
const endYear = Math.max(now.getFullYear() + 2, anchor?.getFullYear() ?? now.getFullYear());
const years: number[] = []; const years: number[] = [];
for (let y = startYear; y <= endYear; y += 1) { for (let y = startYear; y <= endYear; y += 1) {
years.push(y); years.push(y);
@@ -72,21 +49,18 @@ const selectClassName = `
bg-background-card/90 text-text-primary text-sm bg-background-card/90 text-text-primary text-sm
pl-2 pr-7 py-1.5 pl-2 pr-7 py-1.5
focus:outline-none focus:ring-2 focus:ring-primary/35 focus:border-border-strong focus:outline-none focus:ring-2 focus:ring-primary/35 focus:border-border-strong
disabled:opacity-50 disabled:cursor-not-allowed
`; `;
export function ScheduleDayPicker({ /**
value, * Calendar day navigator (arrows + year/month/day panel).
onChange, * Does not restrict past dates parent pages enforce read-only vs editable for schedule grids/forms.
minDate, */
label = 'Schedule date', export function ScheduleDayPicker({ value, onChange, label = 'Schedule date' }: ScheduleDayPickerProps) {
}: ScheduleDayPickerProps) {
const panelId = useId(); const panelId = useId();
const rootRef = useRef<HTMLDivElement>(null); const rootRef = useRef<HTMLDivElement>(null);
const [panelOpen, setPanelOpen] = useState(false); const [panelOpen, setPanelOpen] = useState(false);
const normalizedValue = startOfLocalDay(value); const normalizedValue = startOfLocalDay(value);
const normalizedMin = minDate ? startOfLocalDay(minDate) : undefined;
const labelText = normalizedValue.toLocaleDateString(undefined, { const labelText = normalizedValue.toLocaleDateString(undefined, {
weekday: 'short', weekday: 'short',
@@ -95,28 +69,20 @@ export function ScheduleDayPicker({
year: 'numeric', year: 'numeric',
}); });
const previousDay = addCalendarDays(normalizedValue, -1); const years = yearRange(normalizedValue);
const canGoPrevious =
!normalizedMin || compareLocalDayStart(previousDay, normalizedMin) >= 0;
const years = yearRange(normalizedMin, normalizedValue);
const selectedYear = normalizedValue.getFullYear(); const selectedYear = normalizedValue.getFullYear();
const selectedMonth = normalizedValue.getMonth(); const selectedMonth = normalizedValue.getMonth();
const selectedDay = normalizedValue.getDate(); const selectedDay = normalizedValue.getDate();
const dayCount = daysInMonth(selectedYear, selectedMonth); const dayCount = daysInMonth(selectedYear, selectedMonth);
function applyParts(year: number, month: number, day: number, closePanel = false) { function applyParts(year: number, month: number, day: number, closePanel = false) {
onChange(clampToValidDay(year, month, day, normalizedMin)); const maxDay = daysInMonth(year, month);
onChange(buildLocalDay(year, month, Math.min(Math.max(1, day), maxDay)));
if (closePanel) { if (closePanel) {
setPanelOpen(false); setPanelOpen(false);
} }
} }
function handlePreviousDay() {
if (!canGoPrevious) return;
onChange(previousDay);
}
useEffect(() => { useEffect(() => {
if (!panelOpen) return; if (!panelOpen) return;
@@ -146,9 +112,8 @@ export function ScheduleDayPicker({
<div className="flex items-center gap-1 rounded-[var(--radius-md)] border border-border bg-background-secondary/90 px-1 py-1 shadow-[inset_0_1px_0_rgba(255,255,255,0.02)]"> <div className="flex items-center gap-1 rounded-[var(--radius-md)] border border-border bg-background-secondary/90 px-1 py-1 shadow-[inset_0_1px_0_rgba(255,255,255,0.02)]">
<button <button
type="button" type="button"
onClick={handlePreviousDay} onClick={() => onChange(addCalendarDays(normalizedValue, -1))}
disabled={!canGoPrevious} className="shrink-0 rounded-[var(--radius-sm)] p-2 text-text-muted hover:text-text-primary hover:bg-background-card/80 focus:outline-none focus:ring-2 focus:ring-primary/35"
className="shrink-0 rounded-[var(--radius-sm)] p-2 text-text-muted hover:text-text-primary hover:bg-background-card/80 focus:outline-none focus:ring-2 focus:ring-primary/35 disabled:opacity-40 disabled:pointer-events-none"
aria-label="Previous day" aria-label="Previous day"
> >
<ChevronLeft className="h-4 w-4 icon-flat" /> <ChevronLeft className="h-4 w-4 icon-flat" />
@@ -232,17 +197,11 @@ export function ScheduleDayPicker({
} }
className={selectClassName} className={selectClassName}
> >
{MONTH_LABELS.map((name, index) => { {MONTH_LABELS.map((name, index) => (
const disabled = <option key={name} value={index}>
normalizedMin && {name}
selectedYear === normalizedMin.getFullYear() && </option>
index < normalizedMin.getMonth(); ))}
return (
<option key={name} value={index} disabled={disabled}>
{name}
</option>
);
})}
</select> </select>
<ChevronDown <ChevronDown
className="pointer-events-none absolute right-1.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-text-muted icon-flat" className="pointer-events-none absolute right-1.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-text-muted icon-flat"
@@ -272,18 +231,11 @@ export function ScheduleDayPicker({
} }
className={selectClassName} className={selectClassName}
> >
{Array.from({ length: dayCount }, (_, i) => i + 1).map((day) => { {Array.from({ length: dayCount }, (_, i) => i + 1).map((day) => (
const disabled = <option key={day} value={day}>
normalizedMin && {day}
selectedYear === normalizedMin.getFullYear() && </option>
selectedMonth === normalizedMin.getMonth() && ))}
day < normalizedMin.getDate();
return (
<option key={day} value={day} disabled={disabled}>
{day}
</option>
);
})}
</select> </select>
<ChevronDown <ChevronDown
className="pointer-events-none absolute right-1.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-text-muted icon-flat" className="pointer-events-none absolute right-1.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-text-muted icon-flat"

View File

@@ -13,10 +13,14 @@ import {
CreditCard, CreditCard,
} from 'lucide-react'; } from 'lucide-react';
import { useAuth } from '@/lib/hooks/useAuth'; import { useAuth } from '@/lib/hooks/useAuth';
import { canAccessAppointmentsSection, canViewTab } from '@/shared/permissions'; import { canAccessAppointmentsSection, canViewTab } from '@/components/shared/permissions';
import {
counterpartOrganizationType,
organizationTypeIcon,
} from '@/components/shared/organizationTypeIcon';
const menu = [ const menu = [
{ name: 'Today', path: '/today', icon: LayoutDashboard, read: 'TAB_TODAY_READ' as const }, { name: 'Dashboard', path: '/today', icon: LayoutDashboard, read: 'TAB_TODAY_READ' as const },
{ name: 'Staff', path: '/staff', icon: UserCog, read: 'TAB_STAFF_READ' as const }, { name: 'Staff', path: '/staff', icon: UserCog, read: 'TAB_STAFF_READ' as const },
{ name: 'Patients', path: '/patients', icon: Users, read: 'TAB_PATIENTS_READ' as const }, { name: 'Patients', path: '/patients', icon: Users, read: 'TAB_PATIENTS_READ' as const },
{ name: 'Appointment', path: '/appointments', icon: Calendar, read: 'TAB_APPOINTMENTS_READ' as const }, { name: 'Appointment', path: '/appointments', icon: Calendar, read: 'TAB_APPOINTMENTS_READ' as const },
@@ -29,6 +33,9 @@ function Sidebar() {
const pathname = usePathname(); const pathname = usePathname();
const { currentOrganization } = useAuth(); const { currentOrganization } = useAuth();
const counterpartLabel = currentOrganization?.type === 'LAB' ? 'Clinics' : 'Labs'; const counterpartLabel = currentOrganization?.type === 'LAB' ? 'Clinics' : 'Labs';
const organizationsTabIcon = organizationTypeIcon(
counterpartOrganizationType(currentOrganization?.type),
);
const visibleMenu = useMemo( const visibleMenu = useMemo(
() => { () => {
@@ -38,7 +45,7 @@ function Sidebar() {
{ {
name: counterpartLabel, name: counterpartLabel,
path: '/organizations', path: '/organizations',
icon: FlaskConical, icon: organizationsTabIcon,
read: 'TAB_ORGANIZATIONS_READ' as const, read: 'TAB_ORGANIZATIONS_READ' as const,
}, },
menu[2], menu[2],
@@ -54,7 +61,7 @@ function Sidebar() {
return canViewTab(currentOrganization, item.read); return canViewTab(currentOrganization, item.read);
}); });
}, },
[counterpartLabel, currentOrganization], [counterpartLabel, organizationsTabIcon, currentOrganization],
); );
return ( return (

View File

@@ -1,5 +1,5 @@
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import type { BadgeVariant } from '@/components/ui/common/Badge'; import type { BadgeVariant } from '@/components/ui/shared/Badge';
interface ToastProps { interface ToastProps {
children: ReactNode; children: ReactNode;

View File

@@ -2,9 +2,9 @@
import { CalendarDays } from 'lucide-react'; import { CalendarDays } from 'lucide-react';
import { purposeStyle } from '@/components/ui/appointments/appointmentPurposeStyles'; import { purposeStyle } from '@/components/ui/appointments/appointmentPurposeStyles';
import { Card } from '@/components/ui/common/Card'; import { Card } from '@/components/ui/shared/Card';
import { ScheduleDayPicker } from '@/components/ui/common/ScheduleDayPicker'; import { ScheduleDayPicker } from '@/components/ui/shared/ScheduleDayPicker';
import { startOfLocalDay } from '@/lib/appointmentTime'; import { startOfLocalDay } from '@/components/appointments/appointmentTime';
import type { TreatmentAppointment } from '@/types/treatment'; import type { TreatmentAppointment } from '@/types/treatment';
interface AppointmentsStripProps { interface AppointmentsStripProps {
@@ -12,8 +12,6 @@ interface AppointmentsStripProps {
onToggleStripHidden: () => void; onToggleStripHidden: () => void;
selectedDay: Date; selectedDay: Date;
onSelectDay: (day: Date) => void; onSelectDay: (day: Date) => void;
/** Same lower bound as Appointments schedule (cannot pick days before this). */
minScheduleDate: Date;
appointments: TreatmentAppointment[]; appointments: TreatmentAppointment[];
selectedAppointmentId: string | null; selectedAppointmentId: string | null;
onSelectAppointment: (id: string) => void; onSelectAppointment: (id: string) => void;
@@ -25,7 +23,6 @@ export function AppointmentsStrip({
onToggleStripHidden, onToggleStripHidden,
selectedDay, selectedDay,
onSelectDay, onSelectDay,
minScheduleDate,
appointments, appointments,
selectedAppointmentId, selectedAppointmentId,
onSelectAppointment, onSelectAppointment,
@@ -65,7 +62,6 @@ export function AppointmentsStrip({
<div className="flex flex-col sm:flex-row sm:items-end gap-4 sm:justify-between"> <div className="flex flex-col sm:flex-row sm:items-end gap-4 sm:justify-between">
<ScheduleDayPicker <ScheduleDayPicker
value={selectedDay} value={selectedDay}
minDate={minScheduleDate}
onChange={(d) => onSelectDay(startOfLocalDay(d))} onChange={(d) => onSelectDay(startOfLocalDay(d))}
/> />
{loading && ( {loading && (

View File

@@ -4,12 +4,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { AppointmentsStrip } from '@/components/ui/treatment/AppointmentsStrip'; import { AppointmentsStrip } from '@/components/ui/treatment/AppointmentsStrip';
import { FdiToothChart } from '@/components/ui/treatment/FdiToothChart'; import { FdiToothChart } from '@/components/ui/treatment/FdiToothChart';
import { PastTreatmentsPanel } from '@/components/ui/treatment/PastTreatmentsPanel'; import { PastTreatmentsPanel } from '@/components/ui/treatment/PastTreatmentsPanel';
import { Checkbox } from '@/components/ui/common/Checkbox'; import { Checkbox } from '@/components/ui/shared/Checkbox';
import { Button } from '@/components/ui/common/Button'; import { Button } from '@/components/ui/shared/Button';
import { Dropdown } from '@/components/ui/common/Dropdown'; import { Dropdown } from '@/components/ui/shared/Dropdown';
import { SearchBar } from '@/components/ui/common/SearchBar'; import { SearchBar } from '@/components/ui/shared/SearchBar';
import { Toast } from '@/components/ui/common/Toast'; import { Toast } from '@/components/ui/shared/Toast';
import { isSameLocalCalendarDay, startOfLocalDay } from '@/lib/appointmentTime'; import { compareLocalDayStart, isSameLocalCalendarDay, startOfLocalDay } from '@/components/appointments/appointmentTime';
import { import {
fetchLinkedOrganizations, fetchLinkedOrganizations,
fetchMyAppointmentsForDay, fetchMyAppointmentsForDay,
@@ -17,8 +17,8 @@ import {
saveTreatmentDraft, saveTreatmentDraft,
sendTreatmentRecord, sendTreatmentRecord,
} from '@/lib/mocks/treatmentMockApi'; } from '@/lib/mocks/treatmentMockApi';
import { pickAutoAppointment } from '@/lib/treatmentSelection'; import { pickAutoAppointment } from '@/components/shared/treatmentSelection';
import { canEditTreatment } from '@/shared/permissions'; import { canEditTreatment } from '@/components/shared/permissions';
import type { Organization } from '@/types/organization'; import type { Organization } from '@/types/organization';
import type { import type {
FdiToothId, FdiToothId,
@@ -54,7 +54,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
const [stripHidden, setStripHidden] = useState(false); const [stripHidden, setStripHidden] = useState(false);
const scheduleMinDate = useMemo(() => startOfLocalDay(new Date()), []); const todayStart = useMemo(() => startOfLocalDay(new Date()), []);
const [selectedDay, setSelectedDay] = useState(() => startOfLocalDay(new Date())); const [selectedDay, setSelectedDay] = useState(() => startOfLocalDay(new Date()));
const [appointments, setAppointments] = useState<TreatmentAppointment[]>([]); const [appointments, setAppointments] = useState<TreatmentAppointment[]>([]);
@@ -87,6 +87,13 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
[appointments, selectedAppointmentId], [appointments, selectedAppointmentId],
); );
const isViewingPastDay = useMemo(
() => compareLocalDayStart(selectedDay, todayStart) < 0,
[selectedDay, todayStart],
);
const canEditTreatmentForDay = Boolean(selectedAppointment) && !isViewingPastDay;
const activeRecord = useMemo( const activeRecord = useMemo(
() => records.find((r) => r.clientId === activeRecordId) ?? records[0], () => records.find((r) => r.clientId === activeRecordId) ?? records[0],
[records, activeRecordId], [records, activeRecordId],
@@ -339,13 +346,19 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
onToggleStripHidden={() => setStripHidden((s) => !s)} onToggleStripHidden={() => setStripHidden((s) => !s)}
selectedDay={selectedDay} selectedDay={selectedDay}
onSelectDay={setSelectedDay} onSelectDay={setSelectedDay}
minScheduleDate={scheduleMinDate}
appointments={appointments} appointments={appointments}
selectedAppointmentId={selectedAppointmentId} selectedAppointmentId={selectedAppointmentId}
onSelectAppointment={onPickAppointment} onSelectAppointment={onPickAppointment}
loading={apptsLoading} loading={apptsLoading}
/> />
{isViewingPastDay && (
<p className="text-sm text-text-secondary rounded-[var(--radius-md)] border border-border/60 bg-background-secondary/50 px-3 py-2">
Past days are view-only. You can review appointments and history, but treatment records
cannot be added or changed.
</p>
)}
<div className="grid grid-cols-1 xl:grid-cols-[minmax(280px,380px)_minmax(0,1fr)] gap-6 items-start"> <div className="grid grid-cols-1 xl:grid-cols-[minmax(280px,380px)_minmax(0,1fr)] gap-6 items-start">
<div className="space-y-4"> <div className="space-y-4">
{selectedAppointment ? ( {selectedAppointment ? (
@@ -423,7 +436,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
<FdiToothChart <FdiToothChart
selected={selectedTeethSet} selected={selectedTeethSet}
onToggle={toggleTooth} onToggle={toggleTooth}
disabled={!selectedAppointment} disabled={!canEditTreatmentForDay}
/> />
<div className="surface-card p-4 space-y-4"> <div className="surface-card p-4 space-y-4">
@@ -437,7 +450,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
<Button <Button
type="button" type="button"
variant="primary" variant="primary"
disabled={!selectedAppointment} disabled={!canEditTreatmentForDay}
onClick={() => { onClick={() => {
const nr = newRecord(); const nr = newRecord();
fixActiveAfterRecordsChange([...records, nr]); fixActiveAfterRecordsChange([...records, nr]);
@@ -486,7 +499,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
}} }}
placeholder="Write clinical notes for this record…" placeholder="Write clinical notes for this record…"
rows={5} rows={5}
disabled={!selectedAppointment} disabled={!canEditTreatmentForDay}
className="mt-1.5 w-full rounded-[var(--radius-md)] border border-border bg-background-secondary/90 text-text-primary text-sm px-3 py-2 placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/35 resize-y min-h-[120px]" className="mt-1.5 w-full rounded-[var(--radius-md)] border border-border bg-background-secondary/90 text-text-primary text-sm px-3 py-2 placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/35 resize-y min-h-[120px]"
/> />
</label> </label>
@@ -503,7 +516,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
), ),
); );
}} }}
disabled={!selectedAppointment} disabled={!canEditTreatmentForDay}
className="capitalize" className="capitalize"
style={{ color: treatmentTypeTextColor }} style={{ color: treatmentTypeTextColor }}
> >
@@ -522,7 +535,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
id="treatment-record-attachments" id="treatment-record-attachments"
type="file" type="file"
multiple multiple
disabled={!selectedAppointment} disabled={!canEditTreatmentForDay}
onChange={(e) => { onChange={(e) => {
addAttachments(e.target.files); addAttachments(e.target.files);
e.target.value = ''; e.target.value = '';
@@ -533,7 +546,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
<Button <Button
type="button" type="button"
variant="primary" variant="primary"
disabled={!selectedAppointment} disabled={!canEditTreatmentForDay}
onClick={() => attachmentInputRef.current?.click()} onClick={() => attachmentInputRef.current?.click()}
aria-controls="treatment-record-attachments" aria-controls="treatment-record-attachments"
> >
@@ -589,7 +602,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
<Checkbox <Checkbox
key={o.id} key={o.id}
checked={activeRecord.sendToOrganizationIds.includes(o.id)} checked={activeRecord.sendToOrganizationIds.includes(o.id)}
disabled={!selectedAppointment || Boolean(activeRecord.sentAt)} disabled={!canEditTreatmentForDay || Boolean(activeRecord.sentAt)}
onChange={(checked) => { onChange={(checked) => {
setRecords((prev) => setRecords((prev) =>
prev.map((r) => { prev.map((r) => {
@@ -614,7 +627,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
<Button <Button
type="button" type="button"
variant="primary" variant="primary"
disabled={!selectedAppointment || Boolean(activeRecord.sentAt) || sendBusyId === activeRecord.clientId} disabled={!canEditTreatmentForDay || Boolean(activeRecord.sentAt) || sendBusyId === activeRecord.clientId}
isLoading={sendBusyId === activeRecord.clientId} isLoading={sendBusyId === activeRecord.clientId}
onClick={() => void handleSendRecord(activeRecord)} onClick={() => void handleSendRecord(activeRecord)}
> >
@@ -633,7 +646,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
<Button <Button
type="button" type="button"
variant="primary" variant="primary"
disabled={!selectedAppointment || saveBusy} disabled={!canEditTreatmentForDay || saveBusy}
isLoading={saveBusy} isLoading={saveBusy}
onClick={() => void handleSaveAll()} onClick={() => void handleSaveAll()}
> >

View File

@@ -7,7 +7,7 @@ export interface StaffMemberDto {
name: string; name: string;
isOwner: boolean; isOwner: boolean;
isActive: boolean; isActive: boolean;
invitationStatus: 'ACTIVE' | 'PENDING' | 'EXPIRED'; invitationStatus: 'ACTIVE' | 'PENDING' | 'EXPIRED' | 'DISABLED';
invitedAt: string | null; invitedAt: string | null;
acceptedAt: string | null; acceptedAt: string | null;
permissions: string[] | null; permissions: string[] | null;
@@ -100,6 +100,20 @@ export const staffApi = {
return response.data; return response.data;
}, },
disableMember: async (
membershipId: string,
): Promise<{ success: boolean; message: string }> => {
const response = await apiClient.patch(`/staff/members/${membershipId}/disable`);
return response.data;
},
enableMember: async (
membershipId: string,
): Promise<{ success: boolean; message: string }> => {
const response = await apiClient.patch(`/staff/members/${membershipId}/enable`);
return response.data;
},
removeMember: async ( removeMember: async (
membershipId: string, membershipId: string,
): Promise<{ success: boolean; message: string }> => { ): Promise<{ success: boolean; message: string }> => {

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import type { ToastMessages } from '@/components/ui/common/Toast'; import type { ToastMessages } from '@/components/ui/shared/Toast';
const DEFAULT_DURATION_MS = 4000; const DEFAULT_DURATION_MS = 4000;

View File

@@ -2,7 +2,7 @@ import {
addCalendarDays, addCalendarDays,
isSameLocalCalendarDay, isSameLocalCalendarDay,
startOfLocalDay, startOfLocalDay,
} from '@/lib/appointmentTime'; } from '@/components/appointments/appointmentTime';
import type { import type {
FdiToothId, FdiToothId,
LinkedOrganizationOption, LinkedOrganizationOption,