diff --git a/backend/src/modules/auth/auth.controller.ts b/backend/src/modules/auth/auth.controller.ts index 860aade..1e68517 100644 --- a/backend/src/modules/auth/auth.controller.ts +++ b/backend/src/modules/auth/auth.controller.ts @@ -126,7 +126,11 @@ export class AuthController { @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Create organization for current user' }) 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, + ); } // ========================= diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts index 7e3a4a1..102e59c 100644 --- a/backend/src/modules/auth/auth.service.ts +++ b/backend/src/modules/auth/auth.service.ts @@ -4,6 +4,7 @@ import { UnauthorizedException, BadRequestException, ConflictException, + ForbiddenException, InternalServerErrorException } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; @@ -270,7 +271,11 @@ export class AuthService { 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({ where: { id: userId }, select: { id: true }, @@ -280,6 +285,28 @@ export class AuthService { 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 createdOrganization = await tx.organization.create({ data: { diff --git a/backend/src/modules/staff/staff.controller.ts b/backend/src/modules/staff/staff.controller.ts index e4732ea..8183f32 100644 --- a/backend/src/modules/staff/staff.controller.ts +++ b/backend/src/modules/staff/staff.controller.ts @@ -80,6 +80,32 @@ export class StaffController { 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') @UseGuards(JwtAuthGuard) @ApiOperation({ summary: 'Remove staff member from organization' }) diff --git a/backend/src/modules/staff/staff.service.ts b/backend/src/modules/staff/staff.service.ts index 2dcd3b2..60f4b23 100644 --- a/backend/src/modules/staff/staff.service.ts +++ b/backend/src/modules/staff/staff.service.ts @@ -7,6 +7,7 @@ import { } from '@nestjs/common'; import * as bcrypt from 'bcrypt'; import { createHash, randomBytes } from 'crypto'; +import { Prisma } from '@prisma/client'; import { PrismaService } from '../../../prisma/prisma.service'; import { AcceptStaffInviteDto } from './dto/accept-staff-invite.dto'; import { isUnlimitedSeats, normalizeTabPermissions } from '../../common/permissions'; @@ -399,6 +400,88 @@ export class StaffService { 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) { const actor = await this.getActorMembership(actorUserId, organizationId); if (!actor || !this.canEditStaff(actor)) { @@ -421,6 +504,37 @@ export class StaffService { 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) { return this.prisma.membership.findFirst({ where: { userId, organizationId }, @@ -435,11 +549,12 @@ export class StaffService { isOwner: boolean; isActive: boolean; invitations: { acceptedAt: Date | null; revokedAt: Date | null; expiresAt: Date }[]; - }): 'ACTIVE' | 'PENDING' | 'EXPIRED' { - if (m.isOwner || m.isActive) return 'ACTIVE'; + }): 'ACTIVE' | 'PENDING' | 'EXPIRED' | 'DISABLED' { + if (m.isOwner) return 'ACTIVE'; + if (m.isActive) return 'ACTIVE'; const invitation = m.invitations[0]; - if (!invitation) return 'EXPIRED'; - if (invitation.acceptedAt) return 'ACTIVE'; + if (invitation?.acceptedAt) return 'DISABLED'; + if (!invitation) return 'DISABLED'; if (invitation.revokedAt) return 'EXPIRED'; return invitation.expiresAt.getTime() > Date.now() ? 'PENDING' : 'EXPIRED'; } diff --git a/frontend/src/app/(dashboard)/appointments/page.tsx b/frontend/src/app/(dashboard)/appointments/page.tsx index 45513c8..ef0152d 100644 --- a/frontend/src/app/(dashboard)/appointments/page.tsx +++ b/frontend/src/app/(dashboard)/appointments/page.tsx @@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { appointmentsApi } from '@/lib/api/appointments'; import { patientsApi } from '@/lib/api/patients'; import { useAuth } from '@/lib/hooks/useAuth'; -import { canEditAppointments, hasPermission } from '@/shared/permissions'; +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'; @@ -13,12 +13,12 @@ import { AppointmentBookingModal } from '@/components/ui/appointments/Appointmen 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/common/ScheduleDayPicker'; -import { ToastStack } from '@/components/ui/common/Toast'; +import { ScheduleDayPicker } from '@/components/ui/shared/ScheduleDayPicker'; +import { ToastStack } from '@/components/ui/shared/Toast'; import { useToast } from '@/lib/hooks/useToast'; import type { AppointmentPurpose } from '@/types/appointment'; -import { formatApiErrorMessage } from '@/lib/formatApiError'; -import { compareLocalDayStart, getLocalDayIsoRange, startOfLocalDay } from '@/lib/appointmentTime'; +import { formatApiErrorMessage } from '@/components/shared/formatApiError'; +import { compareLocalDayStart, getLocalDayIsoRange, startOfLocalDay } from '@/components/appointments/appointmentTime'; const EMPTY_PATIENT_FORM: CreatePatientInput = { firstName: '', @@ -273,6 +273,7 @@ export default function AppointmentsPage() { if (!canEditPatients) { return; } + setPatientForm(EMPTY_PATIENT_FORM); setIsCreateOpen(true); }} /> @@ -285,7 +286,6 @@ export default function AppointmentsPage() {
setScheduleDate(startOfLocalDay(d))} /> {loadingSchedule && ( @@ -323,18 +323,18 @@ export default function AppointmentsPage() { deleting={deletingAppointment} /> - {isCreateOpen && ( -
- setPatientForm((prev) => ({ ...prev, ...patch }))} - onSubmit={() => void handleCreatePatient()} - onClose={() => setIsCreateOpen(false)} - loading={savingPatient} - /> -
- )} + setPatientForm((prev) => ({ ...prev, ...patch }))} + onSubmit={() => void handleCreatePatient()} + onClose={() => { + setIsCreateOpen(false); + setPatientForm(EMPTY_PATIENT_FORM); + }} + loading={savingPatient} + />
); diff --git a/frontend/src/app/(dashboard)/billing/page.tsx b/frontend/src/app/(dashboard)/billing/page.tsx index b28de1c..3d90a1a 100644 --- a/frontend/src/app/(dashboard)/billing/page.tsx +++ b/frontend/src/app/(dashboard)/billing/page.tsx @@ -2,13 +2,13 @@ 'use client'; import { useState } from 'react'; import { Pencil } from 'lucide-react'; -import { Button } from '@/components/ui/common/Button'; -import { Badge } from '@/components/ui/common/Badge'; -import { Card } from '@/components/ui/common/Card'; -import { Table } from '@/components/ui/common/Table'; -import { SearchBar } from '@/components/ui/common/SearchBar'; +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 '@/shared/permissions'; +import { hasPermission } from '@/components/shared/permissions'; // Mock data matching your design const invoices = [ { id: '#123456', patient: 'Ali Rahmani', date: '24/9/2026', service: 'Hygiene', amount: 300, paid: 0, status: 'unpaid' }, diff --git a/frontend/src/app/(dashboard)/layout.tsx b/frontend/src/app/(dashboard)/layout.tsx index 60af0ed..4c0978c 100644 --- a/frontend/src/app/(dashboard)/layout.tsx +++ b/frontend/src/app/(dashboard)/layout.tsx @@ -3,15 +3,15 @@ import { memo, useEffect } from 'react'; import { usePathname, useRouter } from 'next/navigation'; import { useAuth } from '@/lib/hooks/useAuth'; -import Sidebar from '@/components/ui/common/Sidebar'; -import { ThemeToggle } from '@/components/ui/common/ThemeToggle'; +import Sidebar from '@/components/ui/shared/Sidebar'; +import { ThemeToggle } from '@/components/ui/shared/ThemeToggle'; import { DashboardAccountMenu } from '@/components/ui/dashboard/DashboardAccountMenu'; import { canAccessAppointmentsSection, firstAccessibleDashboardPath, getRequiredReadPermissionForPath, hasPermission, -} from '@/shared/permissions'; +} from '@/components/shared/permissions'; export default function DashboardLayout({ children }: { children: React.ReactNode }) { const { user, currentOrganization, isAuthReady } = useAuth(); diff --git a/frontend/src/app/(dashboard)/organizations/page.tsx b/frontend/src/app/(dashboard)/organizations/page.tsx index d581d28..b3c9ddb 100644 --- a/frontend/src/app/(dashboard)/organizations/page.tsx +++ b/frontend/src/app/(dashboard)/organizations/page.tsx @@ -14,12 +14,12 @@ import { import { invitationTargetFromConnectionRow } from '@/components/invitations/organizationInviteLinks'; import { CopyInvitationLinkButton } from '@/components/ui/organizations/CopyInvitationLinkButton'; import { InvitationHistoryDialog } from '@/components/ui/organizations/InvitationHistoryDialog'; -import { Button } from '@/components/ui/common/Button'; -import { Badge, organizationConnectionStatusVariant } from '@/components/ui/common/Badge'; -import { Input } from '@/components/ui/common/Input'; -import { SearchBar } from '@/components/ui/common/SearchBar'; -import { Table } from '@/components/ui/common/Table'; -import { ToastStack } from '@/components/ui/common/Toast'; +import { Button } from '@/components/ui/shared/Button'; +import { Badge, organizationConnectionStatusVariant } from '@/components/ui/shared/Badge'; +import { Input } from '@/components/ui/shared/Input'; +import { SearchBar } from '@/components/ui/shared/SearchBar'; +import { Table } from '@/components/ui/shared/Table'; +import { ToastStack } from '@/components/ui/shared/Toast'; import type { ApiError } from '@/types/api'; function formatOrganizationStatusLabel(status: string): string { diff --git a/frontend/src/app/(dashboard)/patients/page.tsx b/frontend/src/app/(dashboard)/patients/page.tsx index 4095d41..9acc77f 100644 --- a/frontend/src/app/(dashboard)/patients/page.tsx +++ b/frontend/src/app/(dashboard)/patients/page.tsx @@ -1,10 +1,13 @@ 'use client'; 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 { formatApiErrorMessage } from '@/components/shared/formatApiError'; import { useAuth } from '@/lib/hooks/useAuth'; -import { hasPermission } from '@/shared/permissions'; +import { useToast } from '@/lib/hooks/useToast'; +import { hasPermission } from '@/components/shared/permissions'; import { CreatePatientInput, CreateTreatmentHistoryInput, @@ -25,6 +28,7 @@ const EMPTY_PATIENT_FORM: CreatePatientInput = { export default function PatientsPage() { const { currentOrganization } = useAuth(); + const toast = useToast(); const [search, setSearch] = useState(''); const [patients, setPatients] = useState([]); const [selectedPatient, setSelectedPatient] = useState(); @@ -35,8 +39,6 @@ export default function PatientsPage() { const [savingPatient, setSavingPatient] = useState(false); const [savingTreatment, setSavingTreatment] = useState(false); const [patientForm, setPatientForm] = useState(EMPTY_PATIENT_FORM); - const [errorMessage, setErrorMessage] = useState(''); - const [successMessage, setSuccessMessage] = useState(''); const canEditPatients = hasPermission(currentOrganization, 'TAB_PATIENTS_EDIT'); const sortedPatients = useMemo( @@ -58,21 +60,9 @@ export default function PatientsPage() { void loadPatients(''); }, []); - useEffect(() => { - if (!successMessage) { - return; - } - - const timeout = setTimeout(() => { - setSuccessMessage(''); - }, 3000); - - return () => clearTimeout(timeout); - }, [successMessage]); - async function loadPatients(q: string) { setLoadingPatients(true); - setErrorMessage(''); + toast.setError(''); try { const response = await patientsApi.list({ q, page: 1, limit: 25 }); const items = response.data.items; @@ -82,9 +72,8 @@ export default function PatientsPage() { const freshSelected = items.find((item) => item.id === selectedPatient.id); setSelectedPatient(freshSelected); } - } catch (error: any) { - const message = Array.isArray(error?.message) ? error.message.join(', ') : error?.message; - setErrorMessage(message || 'Failed to load patients.'); + } catch (error: unknown) { + toast.showError(formatApiErrorMessage(error, 'Failed to load patients.')); } finally { setLoadingPatients(false); } @@ -92,13 +81,12 @@ export default function PatientsPage() { async function loadTreatments(patientId: string) { setLoadingTreatments(true); - setErrorMessage(''); + toast.setError(''); try { const response = await patientsApi.listTreatments(patientId); setTreatments(response.data); - } catch (error: any) { - const message = Array.isArray(error?.message) ? error.message.join(', ') : error?.message; - setErrorMessage(message || 'Failed to load treatment history.'); + } catch (error: unknown) { + toast.showError(formatApiErrorMessage(error, 'Failed to load treatment history.')); } finally { setLoadingTreatments(false); } @@ -106,8 +94,7 @@ export default function PatientsPage() { async function handleCreatePatient() { setSavingPatient(true); - setErrorMessage(''); - setSuccessMessage(''); + toast.setError(''); try { const response = await patientsApi.create(patientForm); setIsCreateOpen(false); @@ -115,12 +102,11 @@ export default function PatientsPage() { await loadPatients(search); setSelectedPatient(response.data); await loadTreatments(response.data.id); - setSuccessMessage( + toast.showSuccess( `Patient ${response.data.firstName} ${response.data.lastName} was saved successfully.`, ); - } catch (error: any) { - const message = Array.isArray(error?.message) ? error.message.join(', ') : error?.message; - setErrorMessage(message || 'Failed to save patient.'); + } catch (error: unknown) { + toast.showError(formatApiErrorMessage(error, 'Failed to save patient.')); } finally { setSavingPatient(false); } @@ -139,29 +125,29 @@ export default function PatientsPage() { }; setSavingTreatment(true); - setErrorMessage(''); - setSuccessMessage(''); + toast.setError(''); try { await patientsApi.addTreatment(selectedPatient.id, payload); await loadTreatments(selectedPatient.id); - setSuccessMessage('Treatment entry added successfully.'); - } catch (error: any) { - const message = Array.isArray(error?.message) ? error.message.join(', ') : error?.message; - setErrorMessage(message || 'Failed to add treatment entry.'); + toast.showSuccess('Treatment entry added successfully.'); + } catch (error: unknown) { + toast.showError(formatApiErrorMessage(error, 'Failed to add treatment entry.')); } finally { setSavingTreatment(false); } } return ( -
-
+
+

Patients

- setPatientForm((prev) => ({ ...prev, ...patch }))} - onSubmit={handleCreatePatient} - onClose={() => setIsCreateOpen(false)} - loading={savingPatient} - /> + + + {isCreateOpen && ( + setPatientForm((prev) => ({ ...prev, ...patch }))} + onSubmit={() => void handleCreatePatient()} + onClose={() => { + setIsCreateOpen(false); + setPatientForm(EMPTY_PATIENT_FORM); + }} + loading={savingPatient} + /> + )}
@@ -213,21 +206,6 @@ export default function PatientsPage() {
- - {(errorMessage || successMessage) && ( -
- {errorMessage && ( -
- {errorMessage} -
- )} - {successMessage && ( -
- {successMessage} -
- )} -
- )}
); } diff --git a/frontend/src/app/(dashboard)/settings/subscriptions/page.tsx b/frontend/src/app/(dashboard)/settings/subscriptions/page.tsx index e6f6450..7116c8c 100644 --- a/frontend/src/app/(dashboard)/settings/subscriptions/page.tsx +++ b/frontend/src/app/(dashboard)/settings/subscriptions/page.tsx @@ -5,8 +5,8 @@ import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { useAuth } from '@/lib/hooks/useAuth'; import { authApi } from '@/lib/api/auth'; -import { Button } from '@/components/ui/common/Button'; -import { Toast } from '@/components/ui/common/Toast'; +import { Button } from '@/components/ui/shared/Button'; +import { Toast } from '@/components/ui/shared/Toast'; import type { SubscriptionAlertData } from '@/types/subscription'; const PLAN_OPTIONS = [ diff --git a/frontend/src/app/(dashboard)/staff/page.tsx b/frontend/src/app/(dashboard)/staff/page.tsx index 23c60fe..0f8eb87 100644 --- a/frontend/src/app/(dashboard)/staff/page.tsx +++ b/frontend/src/app/(dashboard)/staff/page.tsx @@ -6,7 +6,7 @@ import { firstAccessibleDashboardPath, canEditStaff, canViewStaff, -} from '@/shared/permissions'; +} from '@/components/shared/permissions'; import { STAFF_FEATURE_GROUPS, permissionNamesFromFeatureState, @@ -16,16 +16,18 @@ import { formatAccessSummary, type FeaturePermState, } from '../../../components/staff/staff-permission-form'; -import { Pencil, Trash2, Copy, Check, X } from 'lucide-react'; -import { DialogCloseButton } from '@/components/ui/common/DialogCloseButton'; +import { Pencil, Trash2, Copy, Check, X, UserX, UserCheck } from 'lucide-react'; +import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton'; import { useAuth } from '@/lib/hooks/useAuth'; import { staffApi, type StaffMemberDto } from '@/lib/api/staff'; -import { Button } from '@/components/ui/common/Button'; -import { Badge } from '@/components/ui/common/Badge'; -import { Input } from '@/components/ui/common/Input'; -import { Checkbox } from '@/components/ui/common/Checkbox'; -import { Table } from '@/components/ui/common/Table'; -import type { ApiError } from '@/types/api'; +import { Button } from '@/components/ui/shared/Button'; +import { Badge } from '@/components/ui/shared/Badge'; +import { Input } from '@/components/ui/shared/Input'; +import { Checkbox } from '@/components/ui/shared/Checkbox'; +import { Table } from '@/components/ui/shared/Table'; +import { ToastStack } from '@/components/ui/shared/Toast'; +import { formatApiErrorMessage } from '@/components/shared/formatApiError'; +import { useToast } from '@/lib/hooks/useToast'; type StoredInviteLink = { membershipId: string; @@ -54,16 +56,19 @@ function writeStoredInviteLinks(orgId: string, links: Record(null); const [loading, setLoading] = useState(true); - const [error, setError] = useState(''); - const [success, setSuccess] = useState(''); + const toast = useToast(); const [inviteOpen, setInviteOpen] = useState(false); const [inviteEmail, setInviteEmail] = useState(''); @@ -159,6 +163,10 @@ export default function StaffPage() { const [editName, setEditName] = useState(''); const [editPerms, setEditPerms] = useState(() => emptyFeaturePermissionState()); const [editLoading, setEditLoading] = useState(false); + const [disableTarget, setDisableTarget] = useState(null); + const [disablingMembershipId, setDisablingMembershipId] = useState(null); + const [enableTarget, setEnableTarget] = useState(null); + const [enablingMembershipId, setEnablingMembershipId] = useState(null); const canEdit = useMemo(() => canEditStaff(currentOrganization), [currentOrganization]); const hasActivePlan = Boolean(currentOrganization?.plan); @@ -168,15 +176,21 @@ export default function StaffPage() { return seats.used >= seats.limit; }, [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 () => { - setError(''); + toast.setError(''); setLoading(true); try { const res = await staffApi.list(); setMembers(res.data.members); setSeats(res.data.seats); } catch (e) { - setError(formatApiMessage(e)); + toast.showError(formatApiErrorMessage(e, 'Failed to load staff.')); } finally { setLoading(false); } @@ -221,17 +235,11 @@ export default function StaffPage() { } }, [currentOrganization, router]); - useEffect(() => { - if (!success) return; - const t = setTimeout(() => setSuccess(''), 4000); - return () => clearTimeout(t); - }, [success]); - async function copyStaffInviteLink(member: StaffMemberDto) { if (!canShareStaffInviteLink(member)) return; setCopyingInviteMembershipId(member.id); - setError(''); + toast.setError(''); try { let invitationUrl = pendingInviteLinks[member.id]?.invitationUrl; if (!invitationUrl || member.invitationStatus === 'EXPIRED') { @@ -257,7 +265,7 @@ export default function StaffPage() { await load(); } } catch (e) { - setError(formatApiMessage(e)); + toast.showError(formatApiErrorMessage(e, 'Could not copy invitation link.')); } finally { setCopyingInviteMembershipId(null); } @@ -265,7 +273,7 @@ export default function StaffPage() { async function submitInvite() { setInviteLoading(true); - setError(''); + toast.setError(''); setLastInviteInfo(null); const displayName = inviteName.trim(); const displayEmail = inviteEmail.trim(); @@ -295,14 +303,13 @@ export default function StaffPage() { setPendingInviteLinks(nextLinks); writeStoredInviteLinks(currentOrganization.id, nextLinks); } - setSuccess(''); setInviteOpen(false); setInviteEmail(''); setInviteName(''); setInvitePerms(emptyFeaturePermissionState()); await load(); } catch (e) { - setError(formatApiMessage(e)); + toast.showError(formatApiErrorMessage(e, 'Failed to send invitation.')); } finally { setInviteLoading(false); } @@ -320,36 +327,57 @@ export default function StaffPage() { async function submitEdit() { if (!editing) return; setEditLoading(true); - setError(''); + toast.setError(''); try { await staffApi.updateMember(editing.id, { name: editName.trim(), permissionNames: permissionNamesFromFeatureState(editPerms), }); - setSuccess('Member updated'); + toast.showSuccess('Member updated.'); setEditing(null); await load(); } catch (e) { - setError(formatApiMessage(e)); + toast.showError(formatApiErrorMessage(e, 'Failed to update member.')); } finally { setEditLoading(false); } } - async function removeMember(m: StaffMemberDto) { - if (m.isOwner) return; - if (m.userId === user?.id) { - if (!confirm('Remove yourself from this organization? You will lose access.')) return; - } else { - if (!confirm(`Remove ${m.name} from this organization?`)) return; - } - setError(''); + function handleDeleteMember() { + toast.showError('Delete is not implemented yet.'); + } + + async function confirmDisableMember() { + if (!disableTarget || !canDisableStaff(disableTarget)) return; + + setDisablingMembershipId(disableTarget.id); + toast.setError(''); try { - await staffApi.removeMember(m.id); - setSuccess('Member removed'); + await staffApi.disableMember(disableTarget.id); + toast.showSuccess(`${disableTarget.name} was disabled. A seat is now available.`); + setDisableTarget(null); await load(); } 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() {
+ + {seats && (

Seats:{' '} @@ -400,18 +430,6 @@ export default function StaffPage() {

)} - {error && ( -
- {error} -
- )} - - {success && ( -
- {success} -
- )} - {lastInviteInfo && (
+ )} + {canDisableStaff(m) && ( + + )}
)} + {enableTarget && ( +
+
+
+

+ Enable team member +

+ { + if (enablingMembershipId) return; + setEnableTarget(null); + }} + /> +
+

+ Enable {enableTarget.name} ( + {enableTarget.email})? +

+
    +
  • They can sign in to this organization again with their existing account.
  • +
  • No new invitation is sent and no data was removed while they were disabled.
  • +
  • + Enabling uses one seat on your + plan. +
  • +
+ {!hasAvailableSeat && ( +

+ No seats are available. Disable another member or upgrade your plan before enabling + this person. +

+ )} +
+ + +
+
+
+ )} + + {disableTarget && ( +
+
+
+

+ Disable team member +

+ { + if (disablingMembershipId) return; + setDisableTarget(null); + }} + /> +
+

+ Disable {disableTarget.name} ( + {disableTarget.email})? +

+
    +
  • They will not be able to sign in to this organization.
  • +
  • No data will be removed.
  • +
  • + Disabling frees one seat on your + plan so you can invite someone else. +
  • +
+
+ + +
+
+
+ )} + {editing && (
- + Add New Patient + New Patient )}
diff --git a/frontend/src/components/ui/auth/OrganizationDetailsFields.tsx b/frontend/src/components/ui/auth/OrganizationDetailsFields.tsx index 957646a..d4fd1de 100644 --- a/frontend/src/components/ui/auth/OrganizationDetailsFields.tsx +++ b/frontend/src/components/ui/auth/OrganizationDetailsFields.tsx @@ -2,7 +2,7 @@ import { Building2, Mail } from 'lucide-react'; 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 = { organizationName: string; diff --git a/frontend/src/components/ui/organizations/InvitationHistoryDialog.tsx b/frontend/src/components/ui/organizations/InvitationHistoryDialog.tsx index 1e04b42..10cd2bc 100644 --- a/frontend/src/components/ui/organizations/InvitationHistoryDialog.tsx +++ b/frontend/src/components/ui/organizations/InvitationHistoryDialog.tsx @@ -1,10 +1,10 @@ 'use client'; -import { DialogCloseButton } from '@/components/ui/common/DialogCloseButton'; -import { ToastStack, type ToastMessages } from '@/components/ui/common/Toast'; +import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton'; +import { ToastStack, type ToastMessages } from '@/components/ui/shared/Toast'; import type { OrganizationInvitationHistoryItemDto } from '@/lib/api/organization'; -import { Badge, organizationConnectionStatusVariant } from '@/components/ui/common/Badge'; -import { Table } from '@/components/ui/common/Table'; +import { Badge, organizationConnectionStatusVariant } from '@/components/ui/shared/Badge'; +import { Table } from '@/components/ui/shared/Table'; import { CopyInvitationLinkButton } from '@/components/ui/organizations/CopyInvitationLinkButton'; function formatInvitationStatusLabel(status: OrganizationInvitationHistoryItemDto['status']): string { diff --git a/frontend/src/components/ui/organizations/OrganizationSelectorContent.tsx b/frontend/src/components/ui/organizations/OrganizationSelectorContent.tsx index d997d71..bfe1140 100644 --- a/frontend/src/components/ui/organizations/OrganizationSelectorContent.tsx +++ b/frontend/src/components/ui/organizations/OrganizationSelectorContent.tsx @@ -1,13 +1,26 @@ 'use client'; -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import { useAuth } from '@/lib/hooks/useAuth'; +import { canCreateOrganizationFromCurrentOrg } from '@/components/shared/permissions'; import { Building2, Beaker, Mail } from 'lucide-react'; -import { Input } from '@/components/ui/common/Input'; -import { Button } from '@/components/ui/common/Button'; +import { Input } from '@/components/ui/shared/Input'; +import { Button } from '@/components/ui/shared/Button'; 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 [organizationName, setOrganizationName] = useState(''); const [organizationEmail, setOrganizationEmail] = useState(''); @@ -44,22 +57,26 @@ export function OrganizationSelectorContent() {

Organizations

- 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.'}

- + {canCreateOrganization && ( + + )}
- {isCreateOpen && ( + {canCreateOrganization && isCreateOpen && (
-

No organizations found. Create your first one to continue.

+

+ {canCreateOrganization + ? 'No organizations found. Create your first one to continue.' + : 'No organizations found. Ask an organization owner to invite you.'} +

) : (
diff --git a/frontend/src/components/ui/patient/CreatePatientModal.tsx b/frontend/src/components/ui/patient/CreatePatientModal.tsx index 6d78e63..2a52439 100644 --- a/frontend/src/components/ui/patient/CreatePatientModal.tsx +++ b/frontend/src/components/ui/patient/CreatePatientModal.tsx @@ -1,7 +1,8 @@ 'use client'; -import { Button } from '@/components/ui/common/Button'; -import { Input } from '@/components/ui/common/Input'; +import { Button } from '@/components/ui/shared/Button'; +import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton'; +import { Input } from '@/components/ui/shared/Input'; import { CreatePatientInput } from '@/types/patient'; interface CreatePatientModalProps { @@ -11,22 +12,27 @@ interface CreatePatientModalProps { onSubmit: () => void; onClose: () => void; loading?: boolean; + /** Inline panel on Patients page; centered dialog on Appointments. */ + variant?: 'inline' | 'dialog'; } -export function CreatePatientModal({ - isOpen, +function CreatePatientFormFields({ formData, onChange, onSubmit, onClose, - loading = false, -}: CreatePatientModalProps) { - if (!isOpen) { - return null; - } - + loading, + showCancel, +}: { + formData: CreatePatientInput; + onChange: (patch: Partial) => void; + onSubmit: () => void; + onClose: () => void; + loading: boolean; + showCancel: boolean; +}) { return ( -
+ <>
Save Patient - + {showCancel && ( + + )}
+ + ); +} + +export function CreatePatientModal({ + isOpen, + formData, + onChange, + onSubmit, + onClose, + loading = false, + variant = 'inline', +}: CreatePatientModalProps) { + if (!isOpen) { + return null; + } + + if (variant === 'dialog') { + return ( +
{ + if (e.target === e.currentTarget) { + onClose(); + } + }} + > +
e.stopPropagation()} + > +
+

+ New patient +

+ +
+ + +
+
+ ); + } + + return ( +
+
); } diff --git a/frontend/src/components/ui/patient/PatientSearchSelect.tsx b/frontend/src/components/ui/patient/PatientSearchSelect.tsx index 885dbad..1041080 100644 --- a/frontend/src/components/ui/patient/PatientSearchSelect.tsx +++ b/frontend/src/components/ui/patient/PatientSearchSelect.tsx @@ -1,7 +1,7 @@ 'use client'; import { Search } from 'lucide-react'; -import { Input } from '@/components/ui/common/Input'; +import { Input } from '@/components/ui/shared/Input'; import { Patient } from '@/types/patient'; interface PatientSearchSelectProps { diff --git a/frontend/src/components/ui/common/Badge.tsx b/frontend/src/components/ui/shared/Badge.tsx similarity index 100% rename from frontend/src/components/ui/common/Badge.tsx rename to frontend/src/components/ui/shared/Badge.tsx diff --git a/frontend/src/components/ui/common/Button.tsx b/frontend/src/components/ui/shared/Button.tsx similarity index 100% rename from frontend/src/components/ui/common/Button.tsx rename to frontend/src/components/ui/shared/Button.tsx diff --git a/frontend/src/components/ui/common/Card.tsx b/frontend/src/components/ui/shared/Card.tsx similarity index 100% rename from frontend/src/components/ui/common/Card.tsx rename to frontend/src/components/ui/shared/Card.tsx diff --git a/frontend/src/components/ui/common/Checkbox.tsx b/frontend/src/components/ui/shared/Checkbox.tsx similarity index 100% rename from frontend/src/components/ui/common/Checkbox.tsx rename to frontend/src/components/ui/shared/Checkbox.tsx diff --git a/frontend/src/components/ui/common/DialogCloseButton.tsx b/frontend/src/components/ui/shared/DialogCloseButton.tsx similarity index 100% rename from frontend/src/components/ui/common/DialogCloseButton.tsx rename to frontend/src/components/ui/shared/DialogCloseButton.tsx diff --git a/frontend/src/components/ui/common/Dropdown.tsx b/frontend/src/components/ui/shared/Dropdown.tsx similarity index 100% rename from frontend/src/components/ui/common/Dropdown.tsx rename to frontend/src/components/ui/shared/Dropdown.tsx diff --git a/frontend/src/components/ui/common/Input.tsx b/frontend/src/components/ui/shared/Input.tsx similarity index 100% rename from frontend/src/components/ui/common/Input.tsx rename to frontend/src/components/ui/shared/Input.tsx diff --git a/frontend/src/components/ui/common/OrganizationCard.tsx b/frontend/src/components/ui/shared/OrganizationCard.tsx similarity index 87% rename from frontend/src/components/ui/common/OrganizationCard.tsx rename to frontend/src/components/ui/shared/OrganizationCard.tsx index 0d67055..2d785f1 100644 --- a/frontend/src/components/ui/common/OrganizationCard.tsx +++ b/frontend/src/components/ui/shared/OrganizationCard.tsx @@ -1,7 +1,8 @@ // src/components/ui/OrganizationCard.tsx import React from 'react'; -import { Building2, Beaker, ChevronRight } from 'lucide-react'; +import { ChevronRight } from 'lucide-react'; import type { Organization } from '@/types/organization'; +import { organizationTypeIcon } from '@/components/shared/organizationTypeIcon'; interface OrganizationCardProps { organization: Organization; @@ -12,7 +13,7 @@ export const OrganizationCard: React.FC = ({ organization, onSelect, }) => { - const Icon = organization.type === 'CLINIC' ? Building2 : Beaker; + const Icon = organizationTypeIcon(organization.type); const typeText = organization.type === 'CLINIC' ? 'Dental Clinic' : 'Dental Lab'; return ( diff --git a/frontend/src/components/ui/common/ScheduleDayPicker.tsx b/frontend/src/components/ui/shared/ScheduleDayPicker.tsx similarity index 74% rename from frontend/src/components/ui/common/ScheduleDayPicker.tsx rename to frontend/src/components/ui/shared/ScheduleDayPicker.tsx index 99518f5..e91d297 100644 --- a/frontend/src/components/ui/common/ScheduleDayPicker.tsx +++ b/frontend/src/components/ui/shared/ScheduleDayPicker.tsx @@ -2,17 +2,11 @@ import { useEffect, useId, useRef, useState } from 'react'; import { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react'; -import { - addCalendarDays, - compareLocalDayStart, - startOfLocalDay, -} from '@/lib/appointmentTime'; +import { addCalendarDays, startOfLocalDay } from '@/components/appointments/appointmentTime'; interface ScheduleDayPickerProps { value: Date; onChange: (day: Date) => void; - /** Optional lower bound for day selection and previous-day navigation. */ - minDate?: Date; 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); } -function clampToValidDay( - year: number, - month: number, - day: number, - 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()); +function yearRange(anchor: Date): number[] { + const anchorYear = anchor.getFullYear(); + const startYear = anchorYear - 10; + const endYear = anchorYear + 2; const years: number[] = []; for (let y = startYear; y <= endYear; y += 1) { years.push(y); @@ -72,21 +49,18 @@ const selectClassName = ` bg-background-card/90 text-text-primary text-sm pl-2 pr-7 py-1.5 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, - onChange, - minDate, - label = 'Schedule date', -}: ScheduleDayPickerProps) { +/** + * Calendar day navigator (arrows + year/month/day panel). + * Does not restrict past dates — parent pages enforce read-only vs editable for schedule grids/forms. + */ +export function ScheduleDayPicker({ value, onChange, label = 'Schedule date' }: ScheduleDayPickerProps) { const panelId = useId(); const rootRef = useRef(null); const [panelOpen, setPanelOpen] = useState(false); const normalizedValue = startOfLocalDay(value); - const normalizedMin = minDate ? startOfLocalDay(minDate) : undefined; const labelText = normalizedValue.toLocaleDateString(undefined, { weekday: 'short', @@ -95,28 +69,20 @@ export function ScheduleDayPicker({ year: 'numeric', }); - const previousDay = addCalendarDays(normalizedValue, -1); - const canGoPrevious = - !normalizedMin || compareLocalDayStart(previousDay, normalizedMin) >= 0; - - const years = yearRange(normalizedMin, normalizedValue); + const years = yearRange(normalizedValue); const selectedYear = normalizedValue.getFullYear(); const selectedMonth = normalizedValue.getMonth(); const selectedDay = normalizedValue.getDate(); const dayCount = daysInMonth(selectedYear, selectedMonth); 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) { setPanelOpen(false); } } - function handlePreviousDay() { - if (!canGoPrevious) return; - onChange(previousDay); - } - useEffect(() => { if (!panelOpen) return; @@ -146,9 +112,8 @@ export function ScheduleDayPicker({