From 4590255b31cbf2797fe2b6322e8a9bdb3a208ccd Mon Sep 17 00:00:00 2001 From: Admin Date: Mon, 18 May 2026 12:22:19 +0330 Subject: [PATCH 01/10] bugfix: patients feature toasts unified with the other features. --- .../src/app/(dashboard)/patients/page.tsx | 92 +++++++------------ 1 file changed, 33 insertions(+), 59 deletions(-) diff --git a/frontend/src/app/(dashboard)/patients/page.tsx b/frontend/src/app/(dashboard)/patients/page.tsx index 4095d41..aa85f3d 100644 --- a/frontend/src/app/(dashboard)/patients/page.tsx +++ b/frontend/src/app/(dashboard)/patients/page.tsx @@ -2,8 +2,11 @@ import { useEffect, useMemo, useState } from 'react'; import { Button } from '@/components/ui/common/Button'; +import { ToastStack } from '@/components/ui/common/Toast'; import { patientsApi } from '@/lib/api/patients'; +import { formatApiErrorMessage } from '@/lib/formatApiError'; import { useAuth } from '@/lib/hooks/useAuth'; +import { useToast } from '@/lib/hooks/useToast'; import { hasPermission } from '@/shared/permissions'; import { CreatePatientInput, @@ -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,28 @@ 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)} + loading={savingPatient} + /> + )}
@@ -213,21 +202,6 @@ export default function PatientsPage() {
- - {(errorMessage || successMessage) && ( -
- {errorMessage && ( -
- {errorMessage} -
- )} - {successMessage && ( -
- {successMessage} -
- )} -
- )}
); } -- 2.53.0.windows.1 From eb636db6539b43f3353eaafc4e31e32f9fbe1404 Mon Sep 17 00:00:00 2001 From: Admin Date: Mon, 18 May 2026 12:31:21 +0330 Subject: [PATCH 02/10] bugfix: a small refactor done in folder structure and naming conventions. --- frontend/src/app/(dashboard)/appointments/page.tsx | 6 +++--- frontend/src/app/(dashboard)/billing/page.tsx | 12 ++++++------ frontend/src/app/(dashboard)/layout.tsx | 6 +++--- .../src/app/(dashboard)/organizations/page.tsx | 12 ++++++------ frontend/src/app/(dashboard)/patients/page.tsx | 6 +++--- .../(dashboard)/settings/subscriptions/page.tsx | 4 ++-- frontend/src/app/(dashboard)/staff/page.tsx | 14 +++++++------- frontend/src/app/(dashboard)/today/page.tsx | 2 +- frontend/src/app/(public)/accept-invite/page.tsx | 4 ++-- .../(public)/accept-organization-invite/page.tsx | 4 ++-- frontend/src/app/(public)/login/page.tsx | 4 ++-- frontend/src/app/(public)/page.tsx | 4 ++-- frontend/src/app/(public)/register/page.tsx | 4 ++-- .../src/{ => components}/shared/permissions.ts | 0 .../ui/appointments/AppointmentBookingModal.tsx | 6 +++--- .../ui/appointments/AppointmentOverlapPopover.tsx | 2 +- .../ui/appointments/AppointmentsPatientSearch.tsx | 4 ++-- .../ui/auth/OrganizationDetailsFields.tsx | 2 +- .../ui/organizations/InvitationHistoryDialog.tsx | 8 ++++---- .../organizations/OrganizationSelectorContent.tsx | 4 ++-- .../components/ui/patient/CreatePatientModal.tsx | 4 ++-- .../components/ui/patient/PatientSearchSelect.tsx | 2 +- .../src/components/ui/{common => shared}/Badge.tsx | 0 .../components/ui/{common => shared}/Button.tsx | 0 .../src/components/ui/{common => shared}/Card.tsx | 0 .../components/ui/{common => shared}/Checkbox.tsx | 0 .../ui/{common => shared}/DialogCloseButton.tsx | 0 .../components/ui/{common => shared}/Dropdown.tsx | 0 .../src/components/ui/{common => shared}/Input.tsx | 0 .../ui/{common => shared}/OrganizationCard.tsx | 0 .../ui/{common => shared}/ScheduleDayPicker.tsx | 0 .../components/ui/{common => shared}/SearchBar.tsx | 0 .../components/ui/{common => shared}/Sidebar.tsx | 2 +- .../src/components/ui/{common => shared}/Table.tsx | 0 .../ui/{common => shared}/ThemeToggle.tsx | 0 .../src/components/ui/{common => shared}/Toast.tsx | 2 +- .../components/ui/treatment/AppointmentsStrip.tsx | 4 ++-- .../components/ui/treatment/TreatmentWorkspace.tsx | 12 ++++++------ frontend/src/lib/hooks/useToast.ts | 2 +- 39 files changed, 68 insertions(+), 68 deletions(-) rename frontend/src/{ => components}/shared/permissions.ts (100%) rename frontend/src/components/ui/{common => shared}/Badge.tsx (100%) rename frontend/src/components/ui/{common => shared}/Button.tsx (100%) rename frontend/src/components/ui/{common => shared}/Card.tsx (100%) rename frontend/src/components/ui/{common => shared}/Checkbox.tsx (100%) rename frontend/src/components/ui/{common => shared}/DialogCloseButton.tsx (100%) rename frontend/src/components/ui/{common => shared}/Dropdown.tsx (100%) rename frontend/src/components/ui/{common => shared}/Input.tsx (100%) rename frontend/src/components/ui/{common => shared}/OrganizationCard.tsx (100%) rename frontend/src/components/ui/{common => shared}/ScheduleDayPicker.tsx (100%) rename frontend/src/components/ui/{common => shared}/SearchBar.tsx (100%) rename frontend/src/components/ui/{common => shared}/Sidebar.tsx (97%) rename frontend/src/components/ui/{common => shared}/Table.tsx (100%) rename frontend/src/components/ui/{common => shared}/ThemeToggle.tsx (100%) rename frontend/src/components/ui/{common => shared}/Toast.tsx (97%) diff --git a/frontend/src/app/(dashboard)/appointments/page.tsx b/frontend/src/app/(dashboard)/appointments/page.tsx index 45513c8..bbea8c0 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,8 +13,8 @@ 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'; 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 aa85f3d..ae5ca16 100644 --- a/frontend/src/app/(dashboard)/patients/page.tsx +++ b/frontend/src/app/(dashboard)/patients/page.tsx @@ -1,13 +1,13 @@ 'use client'; import { useEffect, useMemo, useState } from 'react'; -import { Button } from '@/components/ui/common/Button'; -import { ToastStack } from '@/components/ui/common/Toast'; +import { Button } from '@/components/ui/shared/Button'; +import { ToastStack } from '@/components/ui/shared/Toast'; import { patientsApi } from '@/lib/api/patients'; import { formatApiErrorMessage } from '@/lib/formatApiError'; import { useAuth } from '@/lib/hooks/useAuth'; import { useToast } from '@/lib/hooks/useToast'; -import { hasPermission } from '@/shared/permissions'; +import { hasPermission } from '@/components/shared/permissions'; import { CreatePatientInput, CreateTreatmentHistoryInput, 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..ae4c389 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, @@ -17,14 +17,14 @@ import { 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 { 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 { 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 type { ApiError } from '@/types/api'; type StoredInviteLink = { diff --git a/frontend/src/app/(dashboard)/today/page.tsx b/frontend/src/app/(dashboard)/today/page.tsx index b12f05b..9e7ecf7 100644 --- a/frontend/src/app/(dashboard)/today/page.tsx +++ b/frontend/src/app/(dashboard)/today/page.tsx @@ -2,7 +2,7 @@ import Link from 'next/link'; import { useAuth } from '@/lib/hooks/useAuth'; -import { Card } from '@/components/ui/common/Card'; +import { Card } from '@/components/ui/shared/Card'; export default function TodayPage() { const { currentOrganization } = useAuth(); diff --git a/frontend/src/app/(public)/accept-invite/page.tsx b/frontend/src/app/(public)/accept-invite/page.tsx index bfb3b3c..67f6fbb 100644 --- a/frontend/src/app/(public)/accept-invite/page.tsx +++ b/frontend/src/app/(public)/accept-invite/page.tsx @@ -4,8 +4,8 @@ import { useEffect, useMemo, useState } from 'react'; import { Suspense } from 'react'; import Link from 'next/link'; import { useRouter, useSearchParams } from 'next/navigation'; -import { Button } from '@/components/ui/common/Button'; -import { Input } from '@/components/ui/common/Input'; +import { Button } from '@/components/ui/shared/Button'; +import { Input } from '@/components/ui/shared/Input'; import { staffApi } from '@/lib/api/staff'; function AcceptInviteContent() { diff --git a/frontend/src/app/(public)/accept-organization-invite/page.tsx b/frontend/src/app/(public)/accept-organization-invite/page.tsx index 2e34182..782ea2f 100644 --- a/frontend/src/app/(public)/accept-organization-invite/page.tsx +++ b/frontend/src/app/(public)/accept-organization-invite/page.tsx @@ -8,8 +8,8 @@ import type { OrganizationDetailsFormValues } from '@/components/ui/auth/Organiz import { zodResolver } from '@hookform/resolvers/zod'; import * as z from 'zod'; import { Lock, Mail, User } from 'lucide-react'; -import { Button } from '@/components/ui/common/Button'; -import { Input } from '@/components/ui/common/Input'; +import { Button } from '@/components/ui/shared/Button'; +import { Input } from '@/components/ui/shared/Input'; import { OrganizationDetailsFields } from '@/components/ui/auth/OrganizationDetailsFields'; import { RegistrationProgressSteps } from '@/components/ui/auth/RegistrationProgressSteps'; import { organizationApi } from '@/lib/api/organization'; diff --git a/frontend/src/app/(public)/login/page.tsx b/frontend/src/app/(public)/login/page.tsx index 280d948..a0c21ee 100644 --- a/frontend/src/app/(public)/login/page.tsx +++ b/frontend/src/app/(public)/login/page.tsx @@ -116,8 +116,8 @@ import Link from 'next/link'; import { Mail, Lock } from 'lucide-react'; import { useAuth } from '@/lib/hooks/useAuth'; -import { Button } from '@/components/ui/common/Button'; -import { Input } from '@/components/ui/common/Input'; +import { Button } from '@/components/ui/shared/Button'; +import { Input } from '@/components/ui/shared/Input'; const loginSchema = z.object({ email: z.string().email('Please enter a valid email address'), diff --git a/frontend/src/app/(public)/page.tsx b/frontend/src/app/(public)/page.tsx index be60d10..a654924 100644 --- a/frontend/src/app/(public)/page.tsx +++ b/frontend/src/app/(public)/page.tsx @@ -2,8 +2,8 @@ import Link from 'next/link'; import { useAuth } from '@/lib/hooks/useAuth'; -import { Button } from '@/components/ui/common/Button'; -import { ThemeToggle } from '@/components/ui/common/ThemeToggle'; +import { Button } from '@/components/ui/shared/Button'; +import { ThemeToggle } from '@/components/ui/shared/ThemeToggle'; import { Building2, Beaker, Calendar, Shield, Clock, Users } from 'lucide-react'; export default function HomePage() { diff --git a/frontend/src/app/(public)/register/page.tsx b/frontend/src/app/(public)/register/page.tsx index deda726..c1372fe 100644 --- a/frontend/src/app/(public)/register/page.tsx +++ b/frontend/src/app/(public)/register/page.tsx @@ -9,8 +9,8 @@ import { Mail, Lock, User } from 'lucide-react'; import { useAuth } from '@/lib/hooks/useAuth'; import { OrganizationDetailsFields } from '@/components/ui/auth/OrganizationDetailsFields'; import { RegistrationProgressSteps } from '@/components/ui/auth/RegistrationProgressSteps'; -import { Button } from '@/components/ui/common/Button'; -import { Input } from '@/components/ui/common/Input'; +import { Button } from '@/components/ui/shared/Button'; +import { Input } from '@/components/ui/shared/Input'; const registerSchema = z.object({ name: z.string().min(2, 'Name must be at least 2 characters'), email: z.string().email('Please enter a valid email address'), diff --git a/frontend/src/shared/permissions.ts b/frontend/src/components/shared/permissions.ts similarity index 100% rename from frontend/src/shared/permissions.ts rename to frontend/src/components/shared/permissions.ts diff --git a/frontend/src/components/ui/appointments/AppointmentBookingModal.tsx b/frontend/src/components/ui/appointments/AppointmentBookingModal.tsx index 08d01a5..914c053 100644 --- a/frontend/src/components/ui/appointments/AppointmentBookingModal.tsx +++ b/frontend/src/components/ui/appointments/AppointmentBookingModal.tsx @@ -1,9 +1,9 @@ 'use client'; import { useEffect, useState } from 'react'; -import { Button } from '@/components/ui/common/Button'; -import { DialogCloseButton } from '@/components/ui/common/DialogCloseButton'; -import { Dropdown } from '@/components/ui/common/Dropdown'; +import { Button } from '@/components/ui/shared/Button'; +import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton'; +import { Dropdown } from '@/components/ui/shared/Dropdown'; import type { AppointmentPurpose, AppointmentRecord } from '@/types/appointment'; import { APPOINTMENT_PURPOSE_LABEL } from '@/components/ui/appointments/appointmentPurposeStyles'; import type { Patient } from '@/types/patient'; diff --git a/frontend/src/components/ui/appointments/AppointmentOverlapPopover.tsx b/frontend/src/components/ui/appointments/AppointmentOverlapPopover.tsx index 01d7ec5..49cd6ad 100644 --- a/frontend/src/components/ui/appointments/AppointmentOverlapPopover.tsx +++ b/frontend/src/components/ui/appointments/AppointmentOverlapPopover.tsx @@ -1,7 +1,7 @@ 'use client'; import { useEffect, useRef } from 'react'; -import { DialogCloseButton } from '@/components/ui/common/DialogCloseButton'; +import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton'; import { APPOINTMENT_PURPOSE_LABEL, purposeStyle, diff --git a/frontend/src/components/ui/appointments/AppointmentsPatientSearch.tsx b/frontend/src/components/ui/appointments/AppointmentsPatientSearch.tsx index 717528d..91aa24c 100644 --- a/frontend/src/components/ui/appointments/AppointmentsPatientSearch.tsx +++ b/frontend/src/components/ui/appointments/AppointmentsPatientSearch.tsx @@ -1,8 +1,8 @@ 'use client'; import { Search } from 'lucide-react'; -import { Button } from '@/components/ui/common/Button'; -import { Input } from '@/components/ui/common/Input'; +import { Button } from '@/components/ui/shared/Button'; +import { Input } from '@/components/ui/shared/Input'; import type { Patient } from '@/types/patient'; interface AppointmentsPatientSearchProps { 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..4e70b45 100644 --- a/frontend/src/components/ui/organizations/OrganizationSelectorContent.tsx +++ b/frontend/src/components/ui/organizations/OrganizationSelectorContent.tsx @@ -3,8 +3,8 @@ import { useState } from 'react'; import { useAuth } from '@/lib/hooks/useAuth'; 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(); diff --git a/frontend/src/components/ui/patient/CreatePatientModal.tsx b/frontend/src/components/ui/patient/CreatePatientModal.tsx index 6d78e63..0820cf9 100644 --- a/frontend/src/components/ui/patient/CreatePatientModal.tsx +++ b/frontend/src/components/ui/patient/CreatePatientModal.tsx @@ -1,7 +1,7 @@ 'use client'; -import { Button } from '@/components/ui/common/Button'; -import { Input } from '@/components/ui/common/Input'; +import { Button } from '@/components/ui/shared/Button'; +import { Input } from '@/components/ui/shared/Input'; import { CreatePatientInput } from '@/types/patient'; interface CreatePatientModalProps { 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 100% rename from frontend/src/components/ui/common/OrganizationCard.tsx rename to frontend/src/components/ui/shared/OrganizationCard.tsx diff --git a/frontend/src/components/ui/common/ScheduleDayPicker.tsx b/frontend/src/components/ui/shared/ScheduleDayPicker.tsx similarity index 100% rename from frontend/src/components/ui/common/ScheduleDayPicker.tsx rename to frontend/src/components/ui/shared/ScheduleDayPicker.tsx diff --git a/frontend/src/components/ui/common/SearchBar.tsx b/frontend/src/components/ui/shared/SearchBar.tsx similarity index 100% rename from frontend/src/components/ui/common/SearchBar.tsx rename to frontend/src/components/ui/shared/SearchBar.tsx diff --git a/frontend/src/components/ui/common/Sidebar.tsx b/frontend/src/components/ui/shared/Sidebar.tsx similarity index 97% rename from frontend/src/components/ui/common/Sidebar.tsx rename to frontend/src/components/ui/shared/Sidebar.tsx index 5d959bd..69561c5 100644 --- a/frontend/src/components/ui/common/Sidebar.tsx +++ b/frontend/src/components/ui/shared/Sidebar.tsx @@ -13,7 +13,7 @@ import { CreditCard, } from 'lucide-react'; import { useAuth } from '@/lib/hooks/useAuth'; -import { canAccessAppointmentsSection, canViewTab } from '@/shared/permissions'; +import { canAccessAppointmentsSection, canViewTab } from '@/components/shared/permissions'; const menu = [ { name: 'Today', path: '/today', icon: LayoutDashboard, read: 'TAB_TODAY_READ' as const }, diff --git a/frontend/src/components/ui/common/Table.tsx b/frontend/src/components/ui/shared/Table.tsx similarity index 100% rename from frontend/src/components/ui/common/Table.tsx rename to frontend/src/components/ui/shared/Table.tsx diff --git a/frontend/src/components/ui/common/ThemeToggle.tsx b/frontend/src/components/ui/shared/ThemeToggle.tsx similarity index 100% rename from frontend/src/components/ui/common/ThemeToggle.tsx rename to frontend/src/components/ui/shared/ThemeToggle.tsx diff --git a/frontend/src/components/ui/common/Toast.tsx b/frontend/src/components/ui/shared/Toast.tsx similarity index 97% rename from frontend/src/components/ui/common/Toast.tsx rename to frontend/src/components/ui/shared/Toast.tsx index f3073ca..7d0e31f 100644 --- a/frontend/src/components/ui/common/Toast.tsx +++ b/frontend/src/components/ui/shared/Toast.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from 'react'; -import type { BadgeVariant } from '@/components/ui/common/Badge'; +import type { BadgeVariant } from '@/components/ui/shared/Badge'; interface ToastProps { children: ReactNode; diff --git a/frontend/src/components/ui/treatment/AppointmentsStrip.tsx b/frontend/src/components/ui/treatment/AppointmentsStrip.tsx index b77a795..ddb4405 100644 --- a/frontend/src/components/ui/treatment/AppointmentsStrip.tsx +++ b/frontend/src/components/ui/treatment/AppointmentsStrip.tsx @@ -2,8 +2,8 @@ import { CalendarDays } from 'lucide-react'; import { purposeStyle } from '@/components/ui/appointments/appointmentPurposeStyles'; -import { Card } from '@/components/ui/common/Card'; -import { ScheduleDayPicker } from '@/components/ui/common/ScheduleDayPicker'; +import { Card } from '@/components/ui/shared/Card'; +import { ScheduleDayPicker } from '@/components/ui/shared/ScheduleDayPicker'; import { startOfLocalDay } from '@/lib/appointmentTime'; import type { TreatmentAppointment } from '@/types/treatment'; diff --git a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx index 0c20616..bd2bc9f 100644 --- a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx +++ b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx @@ -4,11 +4,11 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { AppointmentsStrip } from '@/components/ui/treatment/AppointmentsStrip'; import { FdiToothChart } from '@/components/ui/treatment/FdiToothChart'; import { PastTreatmentsPanel } from '@/components/ui/treatment/PastTreatmentsPanel'; -import { Checkbox } from '@/components/ui/common/Checkbox'; -import { Button } from '@/components/ui/common/Button'; -import { Dropdown } from '@/components/ui/common/Dropdown'; -import { SearchBar } from '@/components/ui/common/SearchBar'; -import { Toast } from '@/components/ui/common/Toast'; +import { Checkbox } from '@/components/ui/shared/Checkbox'; +import { Button } from '@/components/ui/shared/Button'; +import { Dropdown } from '@/components/ui/shared/Dropdown'; +import { SearchBar } from '@/components/ui/shared/SearchBar'; +import { Toast } from '@/components/ui/shared/Toast'; import { isSameLocalCalendarDay, startOfLocalDay } from '@/lib/appointmentTime'; import { fetchLinkedOrganizations, @@ -18,7 +18,7 @@ import { sendTreatmentRecord, } from '@/lib/mocks/treatmentMockApi'; import { pickAutoAppointment } from '@/lib/treatmentSelection'; -import { canEditTreatment } from '@/shared/permissions'; +import { canEditTreatment } from '@/components/shared/permissions'; import type { Organization } from '@/types/organization'; import type { FdiToothId, diff --git a/frontend/src/lib/hooks/useToast.ts b/frontend/src/lib/hooks/useToast.ts index 4d36e78..def906c 100644 --- a/frontend/src/lib/hooks/useToast.ts +++ b/frontend/src/lib/hooks/useToast.ts @@ -1,7 +1,7 @@ 'use client'; 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; -- 2.53.0.windows.1 From 3b12c52fd3d95230abec21bab6e9df6b06b7cc8f Mon Sep 17 00:00:00 2001 From: Admin Date: Mon, 18 May 2026 12:50:25 +0330 Subject: [PATCH 03/10] bugfix: selecting past dates is now possible in appointment and treatment features. bur, add, edit and delete actions are disabled for past dates. --- .../src/app/(dashboard)/appointments/page.tsx | 1 - .../ui/shared/ScheduleDayPicker.tsx | 98 +++++-------------- .../ui/treatment/AppointmentsStrip.tsx | 4 - .../ui/treatment/TreatmentWorkspace.tsx | 37 ++++--- 4 files changed, 50 insertions(+), 90 deletions(-) diff --git a/frontend/src/app/(dashboard)/appointments/page.tsx b/frontend/src/app/(dashboard)/appointments/page.tsx index bbea8c0..b40d35e 100644 --- a/frontend/src/app/(dashboard)/appointments/page.tsx +++ b/frontend/src/app/(dashboard)/appointments/page.tsx @@ -285,7 +285,6 @@ export default function AppointmentsPage() {
setScheduleDate(startOfLocalDay(d))} /> {loadingSchedule && ( diff --git a/frontend/src/components/ui/shared/ScheduleDayPicker.tsx b/frontend/src/components/ui/shared/ScheduleDayPicker.tsx index 99518f5..1684f56 100644 --- a/frontend/src/components/ui/shared/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 '@/lib/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({
); diff --git a/frontend/src/app/(dashboard)/patients/page.tsx b/frontend/src/app/(dashboard)/patients/page.tsx index ae5ca16..75a3ccf 100644 --- a/frontend/src/app/(dashboard)/patients/page.tsx +++ b/frontend/src/app/(dashboard)/patients/page.tsx @@ -147,6 +147,7 @@ export default function PatientsPage() { onClick={() => { if (!canEditPatients) return; toast.clear(); + setPatientForm(EMPTY_PATIENT_FORM); setIsCreateOpen(true); }} title={!canEditPatients ? 'Read-only access for this organization.' : undefined} @@ -163,7 +164,10 @@ export default function PatientsPage() { formData={patientForm} onChange={(patch) => setPatientForm((prev) => ({ ...prev, ...patch }))} onSubmit={() => void handleCreatePatient()} - onClose={() => setIsCreateOpen(false)} + onClose={() => { + setIsCreateOpen(false); + setPatientForm(EMPTY_PATIENT_FORM); + }} loading={savingPatient} /> )} diff --git a/frontend/src/components/ui/appointments/AppointmentsPatientSearch.tsx b/frontend/src/components/ui/appointments/AppointmentsPatientSearch.tsx index 91aa24c..e0817fd 100644 --- a/frontend/src/components/ui/appointments/AppointmentsPatientSearch.tsx +++ b/frontend/src/components/ui/appointments/AppointmentsPatientSearch.tsx @@ -49,7 +49,7 @@ export function AppointmentsPatientSearch({ onClick={onAddPatient} title={!canAddPatient ? 'You do not have permission to add patients.' : undefined} > - + Add New Patient + New Patient )}
diff --git a/frontend/src/components/ui/patient/CreatePatientModal.tsx b/frontend/src/components/ui/patient/CreatePatientModal.tsx index 0820cf9..2a52439 100644 --- a/frontend/src/components/ui/patient/CreatePatientModal.tsx +++ b/frontend/src/components/ui/patient/CreatePatientModal.tsx @@ -1,6 +1,7 @@ 'use client'; 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'; @@ -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 ( +
+
); } -- 2.53.0.windows.1 From 95ed1bd4abbab323e7b5d3941d1122e52640e536 Mon Sep 17 00:00:00 2001 From: Admin Date: Mon, 18 May 2026 13:37:20 +0330 Subject: [PATCH 05/10] bugfix: create organization button is now hidden for none owner users. --- backend/src/modules/auth/auth.controller.ts | 6 ++- backend/src/modules/auth/auth.service.ts | 29 ++++++++++- frontend/src/components/shared/permissions.ts | 5 ++ .../OrganizationSelectorContent.tsx | 51 +++++++++++++------ 4 files changed, 74 insertions(+), 17 deletions(-) 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/frontend/src/components/shared/permissions.ts b/frontend/src/components/shared/permissions.ts index 753e73f..2d1733c 100644 --- a/frontend/src/components/shared/permissions.ts +++ b/frontend/src/components/shared/permissions.ts @@ -16,6 +16,11 @@ export function hasPermission(org: Organization | null, permission: string): boo 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 */ export function canViewTab(org: Organization | null, readPermission: string): boolean { return hasPermission(org, readPermission); diff --git a/frontend/src/components/ui/organizations/OrganizationSelectorContent.tsx b/frontend/src/components/ui/organizations/OrganizationSelectorContent.tsx index 4e70b45..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/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.'} +

) : (
-- 2.53.0.windows.1 From 7dba7cc1447f920722d0ddfdb827c3453c5cf107 Mon Sep 17 00:00:00 2001 From: Admin Date: Mon, 18 May 2026 14:08:07 +0330 Subject: [PATCH 06/10] bugfix: organization sidebar icon updated like organization switch feature. --- .../src/app/(dashboard)/appointments/page.tsx | 4 ++-- frontend/src/app/(dashboard)/patients/page.tsx | 2 +- .../appointments}/appointmentOverlapLayout.ts | 0 .../appointments}/appointmentTime.ts | 0 .../{lib => components/shared}/formatApiError.ts | 0 .../components/shared/organizationTypeIcon.ts | 16 ++++++++++++++++ .../shared}/treatmentSelection.ts | 2 +- .../ui/appointments/AppointmentBookingModal.tsx | 2 +- .../ui/appointments/AppointmentScheduleGrid.tsx | 4 ++-- .../OrganizationSelectorContent.tsx | 12 ++++++++---- .../components/ui/shared/OrganizationCard.tsx | 5 +++-- .../components/ui/shared/ScheduleDayPicker.tsx | 2 +- frontend/src/components/ui/shared/Sidebar.tsx | 13 ++++++++++--- .../ui/treatment/AppointmentsStrip.tsx | 2 +- .../ui/treatment/TreatmentWorkspace.tsx | 4 ++-- frontend/src/lib/mocks/treatmentMockApi.ts | 2 +- 16 files changed, 49 insertions(+), 21 deletions(-) rename frontend/src/{lib => components/appointments}/appointmentOverlapLayout.ts (100%) rename frontend/src/{lib => components/appointments}/appointmentTime.ts (100%) rename frontend/src/{lib => components/shared}/formatApiError.ts (100%) create mode 100644 frontend/src/components/shared/organizationTypeIcon.ts rename frontend/src/{lib => components/shared}/treatmentSelection.ts (89%) diff --git a/frontend/src/app/(dashboard)/appointments/page.tsx b/frontend/src/app/(dashboard)/appointments/page.tsx index bf7734b..ef0152d 100644 --- a/frontend/src/app/(dashboard)/appointments/page.tsx +++ b/frontend/src/app/(dashboard)/appointments/page.tsx @@ -17,8 +17,8 @@ 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: '', diff --git a/frontend/src/app/(dashboard)/patients/page.tsx b/frontend/src/app/(dashboard)/patients/page.tsx index 75a3ccf..9acc77f 100644 --- a/frontend/src/app/(dashboard)/patients/page.tsx +++ b/frontend/src/app/(dashboard)/patients/page.tsx @@ -4,7 +4,7 @@ import { useEffect, useMemo, useState } from 'react'; import { Button } from '@/components/ui/shared/Button'; import { ToastStack } from '@/components/ui/shared/Toast'; import { patientsApi } from '@/lib/api/patients'; -import { formatApiErrorMessage } from '@/lib/formatApiError'; +import { formatApiErrorMessage } from '@/components/shared/formatApiError'; import { useAuth } from '@/lib/hooks/useAuth'; import { useToast } from '@/lib/hooks/useToast'; import { hasPermission } from '@/components/shared/permissions'; diff --git a/frontend/src/lib/appointmentOverlapLayout.ts b/frontend/src/components/appointments/appointmentOverlapLayout.ts similarity index 100% rename from frontend/src/lib/appointmentOverlapLayout.ts rename to frontend/src/components/appointments/appointmentOverlapLayout.ts diff --git a/frontend/src/lib/appointmentTime.ts b/frontend/src/components/appointments/appointmentTime.ts similarity index 100% rename from frontend/src/lib/appointmentTime.ts rename to frontend/src/components/appointments/appointmentTime.ts diff --git a/frontend/src/lib/formatApiError.ts b/frontend/src/components/shared/formatApiError.ts similarity index 100% rename from frontend/src/lib/formatApiError.ts rename to frontend/src/components/shared/formatApiError.ts diff --git a/frontend/src/components/shared/organizationTypeIcon.ts b/frontend/src/components/shared/organizationTypeIcon.ts new file mode 100644 index 0000000..99816ab --- /dev/null +++ b/frontend/src/components/shared/organizationTypeIcon.ts @@ -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'; +} diff --git a/frontend/src/lib/treatmentSelection.ts b/frontend/src/components/shared/treatmentSelection.ts similarity index 89% rename from frontend/src/lib/treatmentSelection.ts rename to frontend/src/components/shared/treatmentSelection.ts index 482d00d..e3ac585 100644 --- a/frontend/src/lib/treatmentSelection.ts +++ b/frontend/src/components/shared/treatmentSelection.ts @@ -1,4 +1,4 @@ -import { isSameLocalCalendarDay } from '@/lib/appointmentTime'; +import { isSameLocalCalendarDay } from '@/components/appointments/appointmentTime'; import type { TreatmentAppointment } from '@/types/treatment'; /** diff --git a/frontend/src/components/ui/appointments/AppointmentBookingModal.tsx b/frontend/src/components/ui/appointments/AppointmentBookingModal.tsx index 914c053..17921df 100644 --- a/frontend/src/components/ui/appointments/AppointmentBookingModal.tsx +++ b/frontend/src/components/ui/appointments/AppointmentBookingModal.tsx @@ -12,7 +12,7 @@ import { compareLocalDayStart, formatTimeForInput, isSameLocalCalendarDay, -} from '@/lib/appointmentTime'; +} from '@/components/appointments/appointmentTime'; interface AppointmentBookingModalProps { open: boolean; diff --git a/frontend/src/components/ui/appointments/AppointmentScheduleGrid.tsx b/frontend/src/components/ui/appointments/AppointmentScheduleGrid.tsx index 13f2912..041d050 100644 --- a/frontend/src/components/ui/appointments/AppointmentScheduleGrid.tsx +++ b/frontend/src/components/ui/appointments/AppointmentScheduleGrid.tsx @@ -2,12 +2,12 @@ import { useMemo, useState } from 'react'; import type { AppointmentColumnProvider, AppointmentRecord } from '@/types/appointment'; -import { formatHourLabel } from '@/lib/appointmentTime'; +import { formatHourLabel } from '@/components/appointments/appointmentTime'; import { computeAppointmentLaneLayouts, findOverlapCluster, lanePositionStyles, -} from '@/lib/appointmentOverlapLayout'; +} from '@/components/appointments/appointmentOverlapLayout'; import { purposeStyle } from '@/components/ui/appointments/appointmentPurposeStyles'; import { AppointmentOverlapPopover } from '@/components/ui/appointments/AppointmentOverlapPopover'; diff --git a/frontend/src/components/ui/organizations/OrganizationSelectorContent.tsx b/frontend/src/components/ui/organizations/OrganizationSelectorContent.tsx index bfe1140..3283092 100644 --- a/frontend/src/components/ui/organizations/OrganizationSelectorContent.tsx +++ b/frontend/src/components/ui/organizations/OrganizationSelectorContent.tsx @@ -3,7 +3,9 @@ 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 { Building2, Mail } from 'lucide-react'; +import type { Organization } from '@/types/organization'; +import { organizationTypeIcon } from '@/components/shared/organizationTypeIcon'; import { Input } from '@/components/ui/shared/Input'; import { Button } from '@/components/ui/shared/Button'; @@ -26,8 +28,10 @@ export function OrganizationSelectorContent() { const [organizationEmail, setOrganizationEmail] = useState(''); const [organizationType, setOrganizationType] = useState<'CLINIC' | 'LAB'>('CLINIC'); - const getIcon = (type: string) => - type === 'CLINIC' ? : ; + const renderOrgTypeIcon = (type: Organization['type']) => { + const Icon = organizationTypeIcon(type); + return ; + }; const handleCreateOrganization = async () => { try { @@ -158,7 +162,7 @@ export function OrganizationSelectorContent() { className="surface-card p-6 transition-all text-left flex items-center gap-4 hover:border-primary/60" >
- {getIcon(org.type)} + {renderOrgTypeIcon(org.type)}
diff --git a/frontend/src/components/ui/shared/OrganizationCard.tsx b/frontend/src/components/ui/shared/OrganizationCard.tsx index 0d67055..2d785f1 100644 --- a/frontend/src/components/ui/shared/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/shared/ScheduleDayPicker.tsx b/frontend/src/components/ui/shared/ScheduleDayPicker.tsx index 1684f56..e91d297 100644 --- a/frontend/src/components/ui/shared/ScheduleDayPicker.tsx +++ b/frontend/src/components/ui/shared/ScheduleDayPicker.tsx @@ -2,7 +2,7 @@ import { useEffect, useId, useRef, useState } from 'react'; import { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react'; -import { addCalendarDays, startOfLocalDay } from '@/lib/appointmentTime'; +import { addCalendarDays, startOfLocalDay } from '@/components/appointments/appointmentTime'; interface ScheduleDayPickerProps { value: Date; diff --git a/frontend/src/components/ui/shared/Sidebar.tsx b/frontend/src/components/ui/shared/Sidebar.tsx index 69561c5..80bc7f8 100644 --- a/frontend/src/components/ui/shared/Sidebar.tsx +++ b/frontend/src/components/ui/shared/Sidebar.tsx @@ -14,9 +14,13 @@ import { } from 'lucide-react'; import { useAuth } from '@/lib/hooks/useAuth'; import { canAccessAppointmentsSection, canViewTab } from '@/components/shared/permissions'; +import { + counterpartOrganizationType, + organizationTypeIcon, +} from '@/components/shared/organizationTypeIcon'; 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: 'Patients', path: '/patients', icon: Users, read: 'TAB_PATIENTS_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 { currentOrganization } = useAuth(); const counterpartLabel = currentOrganization?.type === 'LAB' ? 'Clinics' : 'Labs'; + const organizationsTabIcon = organizationTypeIcon( + counterpartOrganizationType(currentOrganization?.type), + ); const visibleMenu = useMemo( () => { @@ -38,7 +45,7 @@ function Sidebar() { { name: counterpartLabel, path: '/organizations', - icon: FlaskConical, + icon: organizationsTabIcon, read: 'TAB_ORGANIZATIONS_READ' as const, }, menu[2], @@ -54,7 +61,7 @@ function Sidebar() { return canViewTab(currentOrganization, item.read); }); }, - [counterpartLabel, currentOrganization], + [counterpartLabel, organizationsTabIcon, currentOrganization], ); return ( diff --git a/frontend/src/components/ui/treatment/AppointmentsStrip.tsx b/frontend/src/components/ui/treatment/AppointmentsStrip.tsx index 15cc3a4..0a5cfb6 100644 --- a/frontend/src/components/ui/treatment/AppointmentsStrip.tsx +++ b/frontend/src/components/ui/treatment/AppointmentsStrip.tsx @@ -4,7 +4,7 @@ import { CalendarDays } from 'lucide-react'; import { purposeStyle } from '@/components/ui/appointments/appointmentPurposeStyles'; import { Card } from '@/components/ui/shared/Card'; import { ScheduleDayPicker } from '@/components/ui/shared/ScheduleDayPicker'; -import { startOfLocalDay } from '@/lib/appointmentTime'; +import { startOfLocalDay } from '@/components/appointments/appointmentTime'; import type { TreatmentAppointment } from '@/types/treatment'; interface AppointmentsStripProps { diff --git a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx index 1e7230f..ed39b37 100644 --- a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx +++ b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx @@ -9,7 +9,7 @@ import { Button } from '@/components/ui/shared/Button'; import { Dropdown } from '@/components/ui/shared/Dropdown'; import { SearchBar } from '@/components/ui/shared/SearchBar'; import { Toast } from '@/components/ui/shared/Toast'; -import { compareLocalDayStart, isSameLocalCalendarDay, startOfLocalDay } from '@/lib/appointmentTime'; +import { compareLocalDayStart, isSameLocalCalendarDay, startOfLocalDay } from '@/components/appointments/appointmentTime'; import { fetchLinkedOrganizations, fetchMyAppointmentsForDay, @@ -17,7 +17,7 @@ import { saveTreatmentDraft, sendTreatmentRecord, } from '@/lib/mocks/treatmentMockApi'; -import { pickAutoAppointment } from '@/lib/treatmentSelection'; +import { pickAutoAppointment } from '@/components/shared/treatmentSelection'; import { canEditTreatment } from '@/components/shared/permissions'; import type { Organization } from '@/types/organization'; import type { diff --git a/frontend/src/lib/mocks/treatmentMockApi.ts b/frontend/src/lib/mocks/treatmentMockApi.ts index 21dbff9..93d1482 100644 --- a/frontend/src/lib/mocks/treatmentMockApi.ts +++ b/frontend/src/lib/mocks/treatmentMockApi.ts @@ -2,7 +2,7 @@ import { addCalendarDays, isSameLocalCalendarDay, startOfLocalDay, -} from '@/lib/appointmentTime'; +} from '@/components/appointments/appointmentTime'; import type { FdiToothId, LinkedOrganizationOption, -- 2.53.0.windows.1 From 81fe14823f481a88b857656a1dca55a1008fcc8a Mon Sep 17 00:00:00 2001 From: Admin Date: Mon, 18 May 2026 14:20:50 +0330 Subject: [PATCH 07/10] bugfix: staffs feature toasts unified with the other features. --- frontend/src/app/(dashboard)/staff/page.tsx | 64 +++++++-------------- 1 file changed, 20 insertions(+), 44 deletions(-) diff --git a/frontend/src/app/(dashboard)/staff/page.tsx b/frontend/src/app/(dashboard)/staff/page.tsx index ae4c389..958e489 100644 --- a/frontend/src/app/(dashboard)/staff/page.tsx +++ b/frontend/src/app/(dashboard)/staff/page.tsx @@ -25,7 +25,9 @@ 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 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 = { membershipId: string; @@ -54,14 +56,6 @@ 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(''); @@ -169,14 +162,14 @@ export default function StaffPage() { }, [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 +214,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 +244,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 +252,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 +282,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,17 +306,17 @@ 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); } @@ -343,13 +329,13 @@ export default function StaffPage() { } else { if (!confirm(`Remove ${m.name} from this organization?`)) return; } - setError(''); + toast.setError(''); try { await staffApi.removeMember(m.id); - setSuccess('Member removed'); + toast.showSuccess('Member removed.'); await load(); } catch (e) { - setError(formatApiMessage(e)); + toast.showError(formatApiErrorMessage(e, 'Failed to remove member.')); } } @@ -383,6 +369,8 @@ export default function StaffPage() {
+ + {seats && (

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

)} - {error && ( -
- {error} -
- )} - - {success && ( -
- {success} -
- )} - {lastInviteInfo && (
+ )}
)} + {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 && (
=> { + const response = await apiClient.patch(`/staff/members/${membershipId}/disable`); + return response.data; + }, + removeMember: async ( membershipId: string, ): Promise<{ success: boolean; message: string }> => { -- 2.53.0.windows.1 From 10d8fac3f8b5e01000f1f105c6f8371ea9f2ccd5 Mon Sep 17 00:00:00 2001 From: Admin Date: Mon, 18 May 2026 14:58:47 +0330 Subject: [PATCH 09/10] bugfix: a new flow added to disable staffs and free the used seats. --- backend/src/modules/auth/auth.service.ts | 13 +--------- .../OrganizationSelectorContent.tsx | 26 +++++-------------- 2 files changed, 8 insertions(+), 31 deletions(-) diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts index 7e3cad7..102e59c 100644 --- a/backend/src/modules/auth/auth.service.ts +++ b/backend/src/modules/auth/auth.service.ts @@ -756,18 +756,7 @@ export class AuthService { throw new UnauthorizedException('Access denied to this organization'); } if (!membership.isOwner && !membership.isActive) { - const acceptedInvite = await this.prisma.staffInvitation.findFirst({ - where: { - membershipId: membership.id, - acceptedAt: { not: null }, - }, - select: { id: true }, - }); - throw new UnauthorizedException( - acceptedInvite - ? 'Your access to this organization has been disabled.' - : 'Your invitation is still pending activation.', - ); + throw new UnauthorizedException('Your invitation is still pending activation'); } // 2. Build payload WITH org context diff --git a/frontend/src/components/ui/organizations/OrganizationSelectorContent.tsx b/frontend/src/components/ui/organizations/OrganizationSelectorContent.tsx index 3283092..b98e5f4 100644 --- a/frontend/src/components/ui/organizations/OrganizationSelectorContent.tsx +++ b/frontend/src/components/ui/organizations/OrganizationSelectorContent.tsx @@ -3,35 +3,23 @@ import { useMemo, useState } from 'react'; import { useAuth } from '@/lib/hooks/useAuth'; import { canCreateOrganizationFromCurrentOrg } from '@/components/shared/permissions'; -import { Building2, Mail } from 'lucide-react'; -import type { Organization } from '@/types/organization'; -import { organizationTypeIcon } from '@/components/shared/organizationTypeIcon'; +import { Building2, Beaker, Mail } from 'lucide-react'; import { Input } from '@/components/ui/shared/Input'; import { Button } from '@/components/ui/shared/Button'; export function OrganizationSelectorContent() { - const { - organizations, - currentOrganization, - selectOrganization, - createOrganization, - isLoading, - error, - clearError, - } = useAuth(); + const { organizations, selectOrganization, createOrganization, isLoading, error, clearError } = useAuth(); const canCreateOrganization = useMemo( - () => canCreateOrganizationFromCurrentOrg(currentOrganization), - [currentOrganization], + () => canUserCreateOrganization(organizations), + [organizations], ); const [isCreateOpen, setIsCreateOpen] = useState(false); const [organizationName, setOrganizationName] = useState(''); const [organizationEmail, setOrganizationEmail] = useState(''); const [organizationType, setOrganizationType] = useState<'CLINIC' | 'LAB'>('CLINIC'); - const renderOrgTypeIcon = (type: Organization['type']) => { - const Icon = organizationTypeIcon(type); - return ; - }; + const getIcon = (type: string) => + type === 'CLINIC' ? : ; const handleCreateOrganization = async () => { try { @@ -162,7 +150,7 @@ export function OrganizationSelectorContent() { className="surface-card p-6 transition-all text-left flex items-center gap-4 hover:border-primary/60" >
- {renderOrgTypeIcon(org.type)} + {getIcon(org.type)}
-- 2.53.0.windows.1 From 3a0f5cbc6e1d2de84b946acd828c1901632b9a60 Mon Sep 17 00:00:00 2001 From: Admin Date: Mon, 18 May 2026 19:03:52 +0330 Subject: [PATCH 10/10] bugfix: a new flow added to re-enable disabled staffs. --- backend/src/modules/staff/staff.controller.ts | 13 +++ backend/src/modules/staff/staff.service.ts | 76 ++++++++++++ frontend/src/app/(dashboard)/staff/page.tsx | 110 +++++++++++++++++- .../OrganizationSelectorContent.tsx | 14 ++- frontend/src/lib/api/staff.ts | 7 ++ 5 files changed, 216 insertions(+), 4 deletions(-) diff --git a/backend/src/modules/staff/staff.controller.ts b/backend/src/modules/staff/staff.controller.ts index 40e5ca3..8183f32 100644 --- a/backend/src/modules/staff/staff.controller.ts +++ b/backend/src/modules/staff/staff.controller.ts @@ -80,6 +80,19 @@ 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({ diff --git a/backend/src/modules/staff/staff.service.ts b/backend/src/modules/staff/staff.service.ts index e5355f2..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,50 @@ 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)) { @@ -459,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 }, diff --git a/frontend/src/app/(dashboard)/staff/page.tsx b/frontend/src/app/(dashboard)/staff/page.tsx index 98df259..0f8eb87 100644 --- a/frontend/src/app/(dashboard)/staff/page.tsx +++ b/frontend/src/app/(dashboard)/staff/page.tsx @@ -16,7 +16,7 @@ import { formatAccessSummary, type FeaturePermState, } from '../../../components/staff/staff-permission-form'; -import { Pencil, Trash2, Copy, Check, X, UserX } from 'lucide-react'; +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'; @@ -67,6 +67,10 @@ function canDisableStaff(member: StaffMemberDto): boolean { return !member.isOwner && member.isActive; } +function canEnableStaff(member: StaffMemberDto): boolean { + return !member.isOwner && member.invitationStatus === 'DISABLED'; +} + function PermissionGrid({ state, onChange, @@ -161,6 +165,8 @@ export default function StaffPage() { 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); @@ -170,6 +176,12 @@ 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 () => { toast.setError(''); setLoading(true); @@ -352,6 +364,23 @@ export default function StaffPage() { } } + 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); + } + } + if (!currentOrganization || !canViewStaff(currentOrganization)) { return (

Redirecting…

@@ -547,6 +576,25 @@ export default function StaffPage() { )} )} + {canEnableStaff(m) && ( + + )} {canDisableStaff(m) && ( + +
+
+
+ )} + {disableTarget && (
canUserCreateOrganization(organizations), - [organizations], + () => canCreateOrganizationFromCurrentOrg(currentOrganization), + [currentOrganization], ); const [isCreateOpen, setIsCreateOpen] = useState(false); const [organizationName, setOrganizationName] = useState(''); diff --git a/frontend/src/lib/api/staff.ts b/frontend/src/lib/api/staff.ts index c25b46e..1b03460 100644 --- a/frontend/src/lib/api/staff.ts +++ b/frontend/src/lib/api/staff.ts @@ -107,6 +107,13 @@ export const staffApi = { 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 ( membershipId: string, ): Promise<{ success: boolean; message: string }> => { -- 2.53.0.windows.1