From 79b76bf2dcec70703e33f4d77aa1e14c2f15d288 Mon Sep 17 00:00:00 2001 From: poduck Date: Tue, 16 Dec 2025 15:20:59 -0500 Subject: [PATCH] Add demo tenant reseed, staff roles, and fix masquerade redirect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Demo Tenant: - Add block_emails field to Tenant model for demo accounts - Add is_email_blocked() and wrapper functions in email_service - Create reseed_demo management command with salon/spa theme - Add Celery beat task for daily reseed at midnight UTC - Create 100 appointments, 20 customers, 13 services, 12 resources Staff Roles: - Add StaffRole model with permission toggles - Create default roles: Full Access, Front Desk, Limited Staff - Add StaffRolesSettings page and hooks - Integrate role assignment in Staff management Bug Fixes: - Fix masquerade redirect using wrong role names (tenant_owner vs owner) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- ESTIMATE.md | 56 ++ frontend/src/App.tsx | 2 + frontend/src/components/Sidebar.tsx | 182 +++-- frontend/src/hooks/useAuth.ts | 4 +- frontend/src/hooks/useInvitations.ts | 3 + frontend/src/hooks/useStaff.ts | 9 +- frontend/src/hooks/useStaffRoles.ts | 95 +++ frontend/src/i18n/locales/en.json | 95 +++ frontend/src/layouts/SettingsLayout.tsx | 7 + frontend/src/pages/Staff.tsx | 86 ++- .../src/pages/settings/StaffRolesSettings.tsx | 483 ++++++++++++ frontend/src/types.ts | 28 + .../communication/messaging/email_service.py | 106 +++ .../identity/core/middleware.py | 5 +- .../migrations/0029_tenant_block_emails.py | 21 + .../smoothschedule/identity/core/mixins.py | 28 +- .../smoothschedule/identity/core/models.py | 6 + .../identity/users/api_views.py | 10 +- .../migrations/0011_add_staff_role_model.py | 49 ++ .../0012_create_default_staff_roles.py | 120 +++ .../smoothschedule/identity/users/models.py | 183 +++++ .../identity/users/staff_permissions.py | 198 +++++ .../identity/users/tests/test_staff_roles.py | 339 +++++++++ .../scheduling/contracts/tasks.py | 10 +- .../management/commands/reseed_demo.py | 702 ++++++++++++++++++ .../0040_add_demo_reseed_periodic_task.py | 54 ++ .../scheduling/schedule/serializers.py | 81 +- .../scheduling/schedule/tasks.py | 19 + .../scheduling/schedule/urls.py | 2 + .../scheduling/schedule/views.py | 90 ++- 30 files changed, 2973 insertions(+), 100 deletions(-) create mode 100644 ESTIMATE.md create mode 100644 frontend/src/hooks/useStaffRoles.ts create mode 100644 frontend/src/pages/settings/StaffRolesSettings.tsx create mode 100644 smoothschedule/smoothschedule/identity/core/migrations/0029_tenant_block_emails.py create mode 100644 smoothschedule/smoothschedule/identity/users/migrations/0011_add_staff_role_model.py create mode 100644 smoothschedule/smoothschedule/identity/users/migrations/0012_create_default_staff_roles.py create mode 100644 smoothschedule/smoothschedule/identity/users/staff_permissions.py create mode 100644 smoothschedule/smoothschedule/identity/users/tests/test_staff_roles.py create mode 100644 smoothschedule/smoothschedule/scheduling/schedule/management/commands/reseed_demo.py create mode 100644 smoothschedule/smoothschedule/scheduling/schedule/migrations/0040_add_demo_reseed_periodic_task.py diff --git a/ESTIMATE.md b/ESTIMATE.md new file mode 100644 index 00000000..2bb36673 --- /dev/null +++ b/ESTIMATE.md @@ -0,0 +1,56 @@ +### **Project Estimate: SmoothSchedule Platform Development** + +**Date:** December 16, 2025 +**Prepared For:** Internal & External Stakeholders +**Prepared By:** Gemini AI Software Engineering Agent + +--- + +#### **1. Executive Summary** + +This document provides a high-level cost and timeline estimate for the from-scratch development of the SmoothSchedule platform. Our analysis of the existing codebase reveals that SmoothSchedule is not merely a scheduling application, but a sophisticated, multi-tenant Software-as-a-Service (SaaS) platform with enterprise-grade features for e-commerce, extensive customization, and developer-level extensibility. + +The architecture includes several high-complexity components, most notably a **sandboxed custom scripting engine** for user-defined automations and a **full data-isolation sandbox mode** for testing. These features place the project in a category of complexity comparable to building a platform-as-a-service (PaaS). + +* **Total Estimated Project Cost:** **$3,500,000 - $5,500,000 USD** +* **Estimated Timeline:** **18 - 24 months** + +--- + +#### **2. Project Scope & Complexity Analysis** + +This estimate is based on the implementation of the following key features and architectural pillars identified in the codebase: + +* **Multi-Tenant SaaS Architecture:** The system is designed to serve multiple businesses (tenants) from a single infrastructure, with complete data separation. +* **Custom Python Scripting Engine:** The platform's most complex feature is its ability to safely execute custom, user-written Python code in a sandboxed environment. This allows for limitless business automation, similar to Zapier or Salesforce Apex, and requires significant investment in security and resource management. +* **Full Data-Isolation Sandbox Mode:** A complete "Test Mode" for each tenant, which uses a separate, isolated database schema. This allows users to safely test workflows and automations without affecting their live business data—a feature typical of mature, enterprise-grade platforms. +* **Extensible Automation & Plugin Framework:** A comprehensive system for creating, installing, and managing "plugins" (automations). These can be triggered by schedules (e.g., cron jobs) or application events (e.g., when an appointment is completed), and are managed via a built-in marketplace. +* **Visual Page Builder:** A drag-and-drop interface (identified as using `@measured/puck`) that allows tenants to build their own public-facing websites and booking pages. +* **Advanced E-commerce Platform:** Integration with Stripe Connect, enabling tenants to bill their own customers, not just for the platform to bill its tenants. This requires complex logic for managed accounts, payment flows, and fee processing. +* **Real-time Capabilities & Asynchronous Tasks:** The platform uses WebSockets (`django-channels`) for live updates and a Celery-based queue for handling background jobs like sending emails, generating reports, and running automations. +* **Mobile Application:** A companion field application for iOS and Android that integrates with the full feature set of the backend. + +--- + +#### **3. Cost Estimate Breakdown** + +This estimate assumes a US-based software development agency model, including project management, design, development, and quality assurance. + +| Category | Description | Estimated Cost Range | +| :---------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------ | +| **Backend Development** | (Python/Django) Includes multi-tenancy, database architecture, REST APIs, and the highly complex scripting engine & sandbox mode. | $1,500,000 - $2,200,000 | +| **Frontend Development** | (React/TypeScript) Includes the primary web application, visual page builder, custom dashboards, and UIs for the automation/scripting engine. | $700,000 - $1,100,000 | +| **Mobile App Development** | (iOS/Android) Native or cross-platform development of the companion field application. | $350,000 - $550,000 | +| **UI/UX Design** | Wireframing, prototyping, and high-fidelity design for the web and mobile applications, ensuring a polished and intuitive user experience. | $250,000 - $400,000 | +| **Project Management & QA** | Management oversight, sprint planning, manual and automated testing, and release coordination across all development tracks. | $550,000 - $900,000 | +| **Third-Party Security Audit** | Essential for a platform that executes custom code. Includes penetration testing and code review by an external security firm. | $100,000 - $350,000 | +| **Total Estimated Cost** | | **$3,500,000 - $5,500,000** | + +--- + +#### **4. Assumptions & Disclaimer** + +* This estimate is based on a feature set inferred from a comprehensive analysis of the provided codebase. +* The costs are calculated using a blended agency rate typical for senior-level engineering talent in the United States. Rates and timelines may vary based on team location and composition. +* This document is a high-level estimate intended for budgetary and planning purposes only. It is **not** a fixed-price quote. A formal proposal would require a detailed discovery and specification phase. +* This estimate covers initial development and deployment. It does **not** include ongoing operational costs such as hosting, third-party service fees (e.g., Stripe, Twilio), marketing, or post-launch maintenance. \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 26f0e51b..cfa7f7fd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -133,6 +133,7 @@ const CommunicationSettings = React.lazy(() => import('./pages/settings/Communic const BillingSettings = React.lazy(() => import('./pages/settings/BillingSettings')); const QuotaSettings = React.lazy(() => import('./pages/settings/QuotaSettings')); const BusinessHoursSettings = React.lazy(() => import('./pages/settings/BusinessHoursSettings')); +const StaffRolesSettings = React.lazy(() => import('./pages/settings/StaffRolesSettings')); import { Toaster } from 'react-hot-toast'; // Import Toaster for notifications @@ -953,6 +954,7 @@ const AppContent: React.FC = () => { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 73b97e5c..3bbc7b73 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -45,12 +45,26 @@ const Sidebar: React.FC = ({ business, user, isCollapsed, toggleCo const logoutMutation = useLogout(); const { canUse } = usePlanFeatures(); + // Helper to check if user has a specific staff permission + // Owners and managers always have all permissions + // Staff members check their effective_permissions (role + user overrides) + const hasPermission = (permissionKey: string): boolean => { + if (role === 'owner' || role === 'manager') { + return true; + } + if (role === 'staff') { + // Check effective_permissions which combines user overrides and staff role + return user.effective_permissions?.[permissionKey] === true; + } + return false; + }; + const canViewAdminPages = role === 'owner' || role === 'manager'; const canViewManagementPages = role === 'owner' || role === 'manager'; const isStaff = role === 'staff'; const canViewSettings = role === 'owner'; - const canViewTickets = role === 'owner' || role === 'manager' || (role === 'staff' && user.can_access_tickets); - const canSendMessages = user.can_send_messages === true; + const canViewTickets = hasPermission('can_access_tickets'); + const canSendMessages = hasPermission('can_access_messages') || user.can_send_messages === true; const handleSignOut = () => { logoutMutation.mutate(); @@ -116,7 +130,7 @@ const Sidebar: React.FC = ({ business, user, isCollapsed, toggleCo isCollapsed={isCollapsed} exact /> - {!isStaff && ( + {hasPermission('can_access_scheduler') && ( = ({ business, user, isCollapsed, toggleCo isCollapsed={isCollapsed} /> )} - {!isStaff && ( + {hasPermission('can_access_tasks') && ( = ({ business, user, isCollapsed, toggleCo badgeElement={} /> )} - {isStaff && ( + {(isStaff && hasPermission('can_access_my_schedule')) && ( = ({ business, user, isCollapsed, toggleCo isCollapsed={isCollapsed} /> )} - {(role === 'staff' || role === 'resource') && ( + {(role === 'staff' || role === 'resource') && hasPermission('can_access_my_availability') && ( = ({ business, user, isCollapsed, toggleCo )} - {/* Manage Section - Staff+ */} - {canViewManagementPages && ( + {/* Manage Section - Show if user has any manage-related permission */} + {(canViewManagementPages || + hasPermission('can_access_site_builder') || + hasPermission('can_access_gallery') || + hasPermission('can_access_customers') || + hasPermission('can_access_services') || + hasPermission('can_access_resources') || + hasPermission('can_access_staff') || + hasPermission('can_access_contracts') || + hasPermission('can_access_time_blocks') || + hasPermission('can_access_locations') + ) && ( - - - } - /> - - - {canViewAdminPages && ( - <> - } - /> - {canUse('contracts') && ( - } - /> - )} - - - + {hasPermission('can_access_site_builder') && ( + + )} + {hasPermission('can_access_gallery') && ( + + )} + {hasPermission('can_access_customers') && ( + } + /> + )} + {hasPermission('can_access_services') && ( + + )} + {hasPermission('can_access_resources') && ( + + )} + {hasPermission('can_access_staff') && ( + } + /> + )} + {hasPermission('can_access_contracts') && canUse('contracts') && ( + } + /> + )} + {hasPermission('can_access_time_blocks') && ( + + )} + {hasPermission('can_access_locations') && ( + )} )} @@ -245,7 +281,7 @@ const Sidebar: React.FC = ({ business, user, isCollapsed, toggleCo )} {/* Money Section - Payments */} - {canViewAdminPages && ( + {hasPermission('can_access_payments') && ( = ({ business, user, isCollapsed, toggleCo )} {/* Extend Section - Automations */} - {canViewAdminPages && ( + {hasPermission('can_access_automations') && ( { /** * Get the redirect path based on user role * Tenant users go to /dashboard/, platform users go to / + * Note: Backend maps tenant_owner -> owner, tenant_manager -> manager, etc. */ const getRedirectPathForRole = (role: string): string => { - const tenantRoles = ['tenant_owner', 'tenant_manager', 'tenant_staff']; + // Tenant roles (as returned by backend after role mapping) + const tenantRoles = ['owner', 'manager', 'staff', 'customer']; if (tenantRoles.includes(role)) { return '/dashboard/'; } diff --git a/frontend/src/hooks/useInvitations.ts b/frontend/src/hooks/useInvitations.ts index c607d903..2a8da9a2 100644 --- a/frontend/src/hooks/useInvitations.ts +++ b/frontend/src/hooks/useInvitations.ts @@ -19,6 +19,8 @@ export interface StaffInvitation { create_bookable_resource: boolean; resource_name: string; permissions: Record; + staff_role_id: number | null; + staff_role_name: string | null; } export interface InvitationDetails { @@ -52,6 +54,7 @@ export interface CreateInvitationData { create_bookable_resource?: boolean; resource_name?: string; permissions?: StaffPermissions; + staff_role_id?: number | null; } /** diff --git a/frontend/src/hooks/useStaff.ts b/frontend/src/hooks/useStaff.ts index 172eedad..08c928aa 100644 --- a/frontend/src/hooks/useStaff.ts +++ b/frontend/src/hooks/useStaff.ts @@ -7,6 +7,7 @@ import apiClient from '../api/client'; export interface StaffPermissions { can_invite_staff?: boolean; + [key: string]: boolean | undefined; } export interface StaffMember { @@ -18,6 +19,9 @@ export interface StaffMember { is_active: boolean; permissions: StaffPermissions; can_invite_staff: boolean; + staff_role_id: number | null; + staff_role_name: string | null; + effective_permissions: Record; } interface StaffFilters { @@ -48,6 +52,9 @@ export const useStaff = (filters?: StaffFilters) => { is_active: s.is_active ?? true, permissions: s.permissions || {}, can_invite_staff: s.can_invite_staff ?? false, + staff_role_id: s.staff_role_id ?? null, + staff_role_name: s.staff_role_name ?? null, + effective_permissions: s.effective_permissions || {}, })); }, retry: false, @@ -66,7 +73,7 @@ export const useUpdateStaff = () => { updates, }: { id: string; - updates: { is_active?: boolean; permissions?: StaffPermissions }; + updates: { is_active?: boolean; permissions?: StaffPermissions; staff_role_id?: number | null }; }) => { const { data } = await apiClient.patch(`/staff/${id}/`, updates); return data; diff --git a/frontend/src/hooks/useStaffRoles.ts b/frontend/src/hooks/useStaffRoles.ts new file mode 100644 index 00000000..05e15de6 --- /dev/null +++ b/frontend/src/hooks/useStaffRoles.ts @@ -0,0 +1,95 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import apiClient from '../api/client'; +import { StaffRole, AvailablePermissions } from '../types'; + +/** + * Hook to fetch all staff roles for the current tenant + */ +export const useStaffRoles = () => { + return useQuery({ + queryKey: ['staffRoles'], + queryFn: async () => { + const { data } = await apiClient.get('/staff-roles/'); + return data; + }, + }); +}; + +/** + * Hook to fetch a single staff role by ID + */ +export const useStaffRole = (id: number | null) => { + return useQuery({ + queryKey: ['staffRoles', id], + queryFn: async () => { + const { data } = await apiClient.get(`/staff-roles/${id}/`); + return data; + }, + enabled: id !== null, + }); +}; + +/** + * Hook to fetch available permission keys and their metadata + */ +export const useAvailablePermissions = () => { + return useQuery({ + queryKey: ['staffRoles', 'availablePermissions'], + queryFn: async () => { + const { data } = await apiClient.get('/staff-roles/available_permissions/'); + return data; + }, + staleTime: 1000 * 60 * 60, // Cache for 1 hour - permissions don't change often + }); +}; + +/** + * Hook to create a new staff role + */ +export const useCreateStaffRole = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (data: Partial) => { + const response = await apiClient.post('/staff-roles/', data); + return response.data as StaffRole; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['staffRoles'] }); + }, + }); +}; + +/** + * Hook to update an existing staff role + */ +export const useUpdateStaffRole = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ id, ...data }: { id: number } & Partial) => { + const response = await apiClient.patch(`/staff-roles/${id}/`, data); + return response.data as StaffRole; + }, + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ queryKey: ['staffRoles'] }); + queryClient.invalidateQueries({ queryKey: ['staffRoles', variables.id] }); + }, + }); +}; + +/** + * Hook to delete a staff role + */ +export const useDeleteStaffRole = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (id: number) => { + await apiClient.delete(`/staff-roles/${id}/`); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['staffRoles'] }); + }, + }); +}; diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index d0754312..77a90c38 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -496,6 +496,10 @@ "reactivateAccount": "Reactivate Account", "deactivateHint": "Prevent this user from logging in while keeping their data", "reactivateHint": "Allow this user to log in again", + "staffRole": "Staff Role", + "noRoleAssigned": "No role assigned", + "selectRole": "Select a role...", + "staffRoleSelectHint": "Assign a role to control which features this staff member can access", "canSendMessages": "Can send broadcast messages", "canSendMessagesHint": "Send messages to groups of staff and customers", "deactivate": "Deactivate", @@ -1316,6 +1320,97 @@ "copiedToClipboard": "Copied to clipboard", "failedToSaveReturnUrl": "Failed to save return URL", "onlyOwnerCanAccess": "Only the business owner can access these settings." + }, + "backToApp": "Back to App", + "sections": { + "business": "Business", + "branding": "Branding", + "integrations": "Integrations", + "access": "Access", + "communication": "Communication", + "billing": "Billing" + }, + "general": { + "title": "General", + "description": "Name, timezone, contact" + }, + "resourceTypes": { + "title": "Resource Types", + "description": "Staff, rooms, equipment" + }, + "businessHours": { + "title": "Business Hours", + "description": "Operating hours" + }, + "appearance": { + "title": "Appearance", + "description": "Logo, colors, theme" + }, + "emailTemplates": { + "title": "Email Templates", + "description": "Customize automated emails" + }, + "customDomains": { + "title": "Custom Domains", + "description": "Use your own domain" + }, + "api": { + "title": "API & Webhooks", + "description": "API tokens, webhooks" + }, + "staffRoles": { + "title": "Staff Roles", + "description": "Role permissions", + "pageTitle": "Staff Roles", + "pageDescription": "Create and manage roles with specific permissions for your staff members.", + "createRole": "Create Role", + "editRole": "Edit Role", + "deleteRole": "Delete Role", + "roleName": "Role Name", + "roleDescription": "Description", + "roleNamePlaceholder": "e.g., Front Desk", + "roleDescriptionPlaceholder": "Brief description of this role's responsibilities", + "permissions": "Permissions", + "menuAccess": "Menu Access", + "dangerousOperations": "Dangerous Operations", + "staffAssigned": "{{count}} staff assigned", + "noStaffAssigned": "No staff assigned", + "defaultRole": "Default", + "cannotDeleteDefault": "Cannot delete default roles", + "cannotDeleteWithStaff": "Cannot delete roles with assigned staff", + "confirmDelete": "Are you sure you want to delete this role?", + "deleteWarning": "This action cannot be undone.", + "noRolesFound": "No staff roles found", + "createFirstRole": "Create your first custom staff role to control what your staff can access.", + "saving": "Saving...", + "save": "Save Role", + "cancel": "Cancel", + "loadingRoles": "Loading roles...", + "loadingPermissions": "Loading permissions...", + "errorLoadingRoles": "Failed to load staff roles", + "errorLoadingPermissions": "Failed to load available permissions", + "roleCreated": "Role created successfully", + "roleUpdated": "Role updated successfully", + "roleDeleted": "Role deleted successfully", + "errorCreating": "Failed to create role", + "errorUpdating": "Failed to update role", + "errorDeleting": "Failed to delete role" + }, + "authentication": { + "title": "Authentication", + "description": "OAuth, social login" + }, + "email": { + "title": "Email Setup", + "description": "Email addresses for tickets" + }, + "smsCalling": { + "title": "SMS & Calling", + "description": "Credits, phone numbers" + }, + "billing": { + "title": "Plan & Billing", + "description": "Subscription, invoices" } }, "profile": { diff --git a/frontend/src/layouts/SettingsLayout.tsx b/frontend/src/layouts/SettingsLayout.tsx index 26ce744f..b3e7b949 100644 --- a/frontend/src/layouts/SettingsLayout.tsx +++ b/frontend/src/layouts/SettingsLayout.tsx @@ -22,6 +22,7 @@ import { AlertTriangle, Calendar, Clock, + Users, } from 'lucide-react'; import { SettingsSidebarSection, @@ -154,6 +155,12 @@ const SettingsLayout: React.FC = () => { {/* Access Section */} + = ({ onMasquerade, effectiveUser }) => { const { data: staffMembers = [], isLoading, error } = useStaff(); const { data: resources = [] } = useResources(); const { data: invitations = [], isLoading: invitationsLoading } = useInvitations(); + const { data: staffRoles = [] } = useStaffRoles(); const createResourceMutation = useCreateResource(); const createInvitationMutation = useCreateInvitation(); const cancelInvitationMutation = useCancelInvitation(); @@ -53,6 +55,7 @@ const Staff: React.FC = ({ onMasquerade, effectiveUser }) => { const [isInviteModalOpen, setIsInviteModalOpen] = useState(false); const [inviteEmail, setInviteEmail] = useState(''); const [inviteRole, setInviteRole] = useState<'TENANT_MANAGER' | 'TENANT_STAFF'>('TENANT_STAFF'); + const [inviteStaffRoleId, setInviteStaffRoleId] = useState(null); const [createBookableResource, setCreateBookableResource] = useState(false); const [resourceName, setResourceName] = useState(''); const [invitePermissions, setInvitePermissions] = useState>({}); @@ -64,6 +67,7 @@ const Staff: React.FC = ({ onMasquerade, effectiveUser }) => { const [isEditModalOpen, setIsEditModalOpen] = useState(false); const [editingStaff, setEditingStaff] = useState(null); const [editPermissions, setEditPermissions] = useState>({}); + const [editStaffRoleId, setEditStaffRoleId] = useState(null); const [editError, setEditError] = useState(''); const [editSuccess, setEditSuccess] = useState(''); @@ -106,6 +110,7 @@ const Staff: React.FC = ({ onMasquerade, effectiveUser }) => { create_bookable_resource: createBookableResource, resource_name: resourceName.trim(), permissions: invitePermissions, + staff_role_id: inviteRole === 'TENANT_STAFF' ? inviteStaffRoleId : null, }; await createInvitationMutation.mutateAsync(invitationData); @@ -114,6 +119,7 @@ const Staff: React.FC = ({ onMasquerade, effectiveUser }) => { setCreateBookableResource(false); setResourceName(''); setInvitePermissions({}); + setInviteStaffRoleId(null); // Close modal after short delay setTimeout(() => { setIsInviteModalOpen(false); @@ -146,6 +152,7 @@ const Staff: React.FC = ({ onMasquerade, effectiveUser }) => { const openInviteModal = () => { setInviteEmail(''); setInviteRole('TENANT_STAFF'); + setInviteStaffRoleId(null); setCreateBookableResource(false); setResourceName(''); setInvitePermissions({}); @@ -190,6 +197,7 @@ const Staff: React.FC = ({ onMasquerade, effectiveUser }) => { const openEditModal = (staff: StaffMember) => { setEditingStaff(staff); setEditPermissions(staff.permissions || {}); + setEditStaffRoleId(staff.staff_role_id); setEditError(''); setEditSuccess(''); setIsEditModalOpen(true); @@ -199,6 +207,7 @@ const Staff: React.FC = ({ onMasquerade, effectiveUser }) => { setIsEditModalOpen(false); setEditingStaff(null); setEditPermissions({}); + setEditStaffRoleId(null); setEditError(''); setEditSuccess(''); }; @@ -208,9 +217,16 @@ const Staff: React.FC = ({ onMasquerade, effectiveUser }) => { setEditError(''); try { + const updates: { permissions: Record; staff_role_id?: number | null } = { + permissions: editPermissions, + }; + // Only include staff_role_id for staff users (not owners/managers) + if (editingStaff.role === 'staff') { + updates.staff_role_id = editStaffRoleId; + } await updateStaffMutation.mutateAsync({ id: editingStaff.id, - updates: { permissions: editPermissions }, + updates, }); setEditSuccess(t('staff.settingsSaved')); setTimeout(() => { @@ -272,7 +288,9 @@ const Staff: React.FC = ({ onMasquerade, effectiveUser }) => {
{invitation.email}
- {invitation.role_display} • {t('staff.expires')}{' '} + {invitation.role_display} + {invitation.staff_role_name && ` (${invitation.staff_role_name})`} + {' '}• {t('staff.expires')}{' '} {new Date(invitation.expires_at).toLocaleDateString()}
@@ -309,6 +327,7 @@ const Staff: React.FC = ({ onMasquerade, effectiveUser }) => { {t('staff.name')} {t('staff.role')} + {t('staff.staffRole')} {t('staff.bookableResource')} {t('common.actions')} @@ -350,6 +369,21 @@ const Staff: React.FC = ({ onMasquerade, effectiveUser }) => { {user.role} + + {user.role === 'staff' ? ( + user.staff_role_name ? ( + + {user.staff_role_name} + + ) : ( + + {t('staff.noRoleAssigned')} + + ) + ) : ( + — + )} + {linkedResource ? ( @@ -533,6 +567,30 @@ const Staff: React.FC = ({ onMasquerade, effectiveUser }) => {

+ {/* Staff Role Selector (only for staff invitations) */} + {inviteRole === 'TENANT_STAFF' && staffRoles.length > 0 && ( +
+ + +

+ {t('staff.staffRoleSelectHint')} +

+
+ )} + {/* Permissions - Using shared component */} {inviteRole === 'TENANT_MANAGER' && ( = ({ onMasquerade, effectiveUser }) => {
+ {/* Staff Role Selector (only for staff users) */} + {editingStaff.role === 'staff' && staffRoles.length > 0 && ( +
+ + +

+ {t('staff.staffRoleSelectHint')} +

+
+ )} + {/* Permissions - Using shared component */} {editingStaff.role === 'manager' && ( { + const { t } = useTranslation(); + const { user } = useOutletContext<{ + business: Business; + user: User; + }>(); + + const { data: staffRoles = [], isLoading } = useStaffRoles(); + const { data: availablePermissions } = useAvailablePermissions(); + const createStaffRole = useCreateStaffRole(); + const updateStaffRole = useUpdateStaffRole(); + const deleteStaffRole = useDeleteStaffRole(); + + const [isModalOpen, setIsModalOpen] = useState(false); + const [editingRole, setEditingRole] = useState(null); + const [formData, setFormData] = useState({ + name: '', + description: '', + permissions: {} as Record, + }); + const [error, setError] = useState(null); + + const isOwner = user.role === 'owner'; + const isManager = user.role === 'manager'; + const canManageRoles = isOwner || isManager; + + // Merge menu and dangerous permissions for display + const allPermissions = useMemo(() => { + if (!availablePermissions) return { menu: {}, dangerous: {} }; + return { + menu: availablePermissions.menu_permissions || {}, + dangerous: availablePermissions.dangerous_permissions || {}, + }; + }, [availablePermissions]); + + const openCreateModal = () => { + setEditingRole(null); + setFormData({ + name: '', + description: '', + permissions: {}, + }); + setError(null); + setIsModalOpen(true); + }; + + const openEditModal = (role: StaffRole) => { + setEditingRole(role); + setFormData({ + name: role.name, + description: role.description || '', + permissions: { ...role.permissions }, + }); + setError(null); + setIsModalOpen(true); + }; + + const closeModal = () => { + setIsModalOpen(false); + setEditingRole(null); + setError(null); + }; + + const togglePermission = (key: string) => { + setFormData((prev) => ({ + ...prev, + permissions: { + ...prev.permissions, + [key]: !prev.permissions[key], + }, + })); + }; + + const toggleAllPermissions = (category: 'menu' | 'dangerous', enable: boolean) => { + const permissions = category === 'menu' ? allPermissions.menu : allPermissions.dangerous; + const updates: Record = {}; + Object.keys(permissions).forEach((key) => { + updates[key] = enable; + }); + setFormData((prev) => ({ + ...prev, + permissions: { + ...prev.permissions, + ...updates, + }, + })); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + + try { + if (editingRole) { + await updateStaffRole.mutateAsync({ + id: editingRole.id, + name: formData.name, + description: formData.description, + permissions: formData.permissions, + }); + } else { + await createStaffRole.mutateAsync({ + name: formData.name, + description: formData.description, + permissions: formData.permissions, + }); + } + closeModal(); + } catch (err: any) { + const message = err.response?.data?.error || err.response?.data?.name?.[0] || 'Failed to save role'; + setError(message); + } + }; + + const handleDelete = async (role: StaffRole) => { + if (!role.can_delete) { + alert( + role.is_default + ? t('settings.staffRoles.cannotDeleteDefault', 'Default roles cannot be deleted.') + : t('settings.staffRoles.cannotDeleteInUse', 'Remove all staff from this role before deleting.') + ); + return; + } + + if (window.confirm(t('settings.staffRoles.confirmDelete', `Are you sure you want to delete the "${role.name}" role?`))) { + try { + await deleteStaffRole.mutateAsync(role.id); + } catch (err: any) { + alert(err.response?.data?.error || 'Failed to delete role'); + } + } + }; + + const countEnabledPermissions = (permissions: Record) => { + return Object.values(permissions).filter(Boolean).length; + }; + + if (!canManageRoles) { + return ( +
+ +

+ {t('settings.staffRoles.noAccess', 'Only the business owner or manager can access these settings.')} +

+
+ ); + } + + return ( +
+ {/* Header */} +
+

+ + {t('settings.staffRoles.title', 'Staff Roles')} +

+

+ {t('settings.staffRoles.subtitle', 'Create roles to control what staff members can access in your business.')} +

+
+ + {/* Roles List */} +
+
+
+

+ {t('settings.staffRoles.yourRoles', 'Your Staff Roles')} +

+

+ {t('settings.staffRoles.rolesDescription', 'Assign staff members to roles to control their permissions.')} +

+
+ +
+ + {isLoading ? ( +
+
+
+ ) : staffRoles.length === 0 ? ( +
+ +

{t('settings.staffRoles.noRoles', 'No staff roles configured.')}

+

{t('settings.staffRoles.createFirst', 'Create your first role to manage staff permissions.')}

+
+ ) : ( +
+ {staffRoles.map((role) => ( +
+
+
+
+ +
+
+

+ {role.name} + {role.is_default && ( + + {t('common.default', 'Default')} + + )} +

+

+ + + {t('settings.staffRoles.staffAssigned', '{{count}} staff', { count: role.staff_count })} + + + + {t('settings.staffRoles.permissionsEnabled', '{{count}} permissions', { + count: countEnabledPermissions(role.permissions), + })} + +

+ {role.description && ( +

+ {role.description} +

+ )} +
+
+
+ + +
+
+
+ ))} +
+ )} +
+ + {/* Create/Edit Modal */} + {isModalOpen && ( +
+
+
+

+ {editingRole + ? t('settings.staffRoles.editRole', 'Edit Role') + : t('settings.staffRoles.createRole', 'Create Role')} +

+ +
+ +
+
+ {error && ( +
+ {error} +
+ )} + + {/* Basic Info */} +
+
+ + setFormData({ ...formData, name: e.target.value })} + required + disabled={editingRole?.is_default} + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500 disabled:opacity-50 disabled:cursor-not-allowed" + placeholder={t('settings.staffRoles.roleNamePlaceholder', 'e.g., Front Desk, Senior Stylist')} + /> +
+ +
+ +