Add demo tenant reseed, staff roles, and fix masquerade redirect
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 = () => {
|
||||
<Route path="email-templates" element={<SystemEmailTemplates />} />
|
||||
<Route path="custom-domains" element={<CustomDomainsSettings />} />
|
||||
<Route path="api" element={<ApiSettings />} />
|
||||
<Route path="staff-roles" element={<StaffRolesSettings />} />
|
||||
<Route path="authentication" element={<AuthenticationSettings />} />
|
||||
<Route path="email" element={<EmailSettings />} />
|
||||
<Route path="sms-calling" element={<CommunicationSettings />} />
|
||||
|
||||
@@ -45,12 +45,26 @@ const Sidebar: React.FC<SidebarProps> = ({ 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<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
||||
isCollapsed={isCollapsed}
|
||||
exact
|
||||
/>
|
||||
{!isStaff && (
|
||||
{hasPermission('can_access_scheduler') && (
|
||||
<SidebarItem
|
||||
to="/dashboard/scheduler"
|
||||
icon={CalendarDays}
|
||||
@@ -124,7 +138,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
)}
|
||||
{!isStaff && (
|
||||
{hasPermission('can_access_tasks') && (
|
||||
<SidebarItem
|
||||
to="/dashboard/tasks"
|
||||
icon={Clock}
|
||||
@@ -134,7 +148,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
||||
badgeElement={<UnfinishedBadge />}
|
||||
/>
|
||||
)}
|
||||
{isStaff && (
|
||||
{(isStaff && hasPermission('can_access_my_schedule')) && (
|
||||
<SidebarItem
|
||||
to="/dashboard/my-schedule"
|
||||
icon={CalendarDays}
|
||||
@@ -142,7 +156,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
)}
|
||||
{(role === 'staff' || role === 'resource') && (
|
||||
{(role === 'staff' || role === 'resource') && hasPermission('can_access_my_availability') && (
|
||||
<SidebarItem
|
||||
to="/dashboard/my-availability"
|
||||
icon={CalendarOff}
|
||||
@@ -152,72 +166,94 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
||||
)}
|
||||
</SidebarSection>
|
||||
|
||||
{/* 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')
|
||||
) && (
|
||||
<SidebarSection title={t('nav.sections.manage', 'Manage')} isCollapsed={isCollapsed}>
|
||||
<SidebarItem
|
||||
to="/dashboard/site-editor"
|
||||
icon={LayoutTemplate}
|
||||
label={t('nav.siteBuilder', 'Site Builder')}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<SidebarItem
|
||||
to="/dashboard/gallery"
|
||||
icon={Image}
|
||||
label={t('nav.gallery', 'Media Gallery')}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<SidebarItem
|
||||
to="/dashboard/customers"
|
||||
icon={Users}
|
||||
label={t('nav.customers')}
|
||||
isCollapsed={isCollapsed}
|
||||
badgeElement={<UnfinishedBadge />}
|
||||
/>
|
||||
<SidebarItem
|
||||
to="/dashboard/services"
|
||||
icon={Briefcase}
|
||||
label={t('nav.services', 'Services')}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<SidebarItem
|
||||
to="/dashboard/resources"
|
||||
icon={ClipboardList}
|
||||
label={t('nav.resources')}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
{canViewAdminPages && (
|
||||
<>
|
||||
<SidebarItem
|
||||
to="/dashboard/staff"
|
||||
icon={Users}
|
||||
label={t('nav.staff')}
|
||||
isCollapsed={isCollapsed}
|
||||
badgeElement={<UnfinishedBadge />}
|
||||
/>
|
||||
{canUse('contracts') && (
|
||||
<SidebarItem
|
||||
to="/dashboard/contracts"
|
||||
icon={FileSignature}
|
||||
label={t('nav.contracts', 'Contracts')}
|
||||
isCollapsed={isCollapsed}
|
||||
badgeElement={<UnfinishedBadge />}
|
||||
/>
|
||||
)}
|
||||
<SidebarItem
|
||||
to="/dashboard/time-blocks"
|
||||
icon={CalendarOff}
|
||||
label={t('nav.timeBlocks', 'Time Blocks')}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<SidebarItem
|
||||
to="/dashboard/locations"
|
||||
icon={MapPin}
|
||||
label={t('nav.locations', 'Locations')}
|
||||
isCollapsed={isCollapsed}
|
||||
locked={!canUse('multi_location')}
|
||||
/>
|
||||
</>
|
||||
{hasPermission('can_access_site_builder') && (
|
||||
<SidebarItem
|
||||
to="/dashboard/site-editor"
|
||||
icon={LayoutTemplate}
|
||||
label={t('nav.siteBuilder', 'Site Builder')}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
)}
|
||||
{hasPermission('can_access_gallery') && (
|
||||
<SidebarItem
|
||||
to="/dashboard/gallery"
|
||||
icon={Image}
|
||||
label={t('nav.gallery', 'Media Gallery')}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
)}
|
||||
{hasPermission('can_access_customers') && (
|
||||
<SidebarItem
|
||||
to="/dashboard/customers"
|
||||
icon={Users}
|
||||
label={t('nav.customers')}
|
||||
isCollapsed={isCollapsed}
|
||||
badgeElement={<UnfinishedBadge />}
|
||||
/>
|
||||
)}
|
||||
{hasPermission('can_access_services') && (
|
||||
<SidebarItem
|
||||
to="/dashboard/services"
|
||||
icon={Briefcase}
|
||||
label={t('nav.services', 'Services')}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
)}
|
||||
{hasPermission('can_access_resources') && (
|
||||
<SidebarItem
|
||||
to="/dashboard/resources"
|
||||
icon={ClipboardList}
|
||||
label={t('nav.resources')}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
)}
|
||||
{hasPermission('can_access_staff') && (
|
||||
<SidebarItem
|
||||
to="/dashboard/staff"
|
||||
icon={Users}
|
||||
label={t('nav.staff')}
|
||||
isCollapsed={isCollapsed}
|
||||
badgeElement={<UnfinishedBadge />}
|
||||
/>
|
||||
)}
|
||||
{hasPermission('can_access_contracts') && canUse('contracts') && (
|
||||
<SidebarItem
|
||||
to="/dashboard/contracts"
|
||||
icon={FileSignature}
|
||||
label={t('nav.contracts', 'Contracts')}
|
||||
isCollapsed={isCollapsed}
|
||||
badgeElement={<UnfinishedBadge />}
|
||||
/>
|
||||
)}
|
||||
{hasPermission('can_access_time_blocks') && (
|
||||
<SidebarItem
|
||||
to="/dashboard/time-blocks"
|
||||
icon={CalendarOff}
|
||||
label={t('nav.timeBlocks', 'Time Blocks')}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
)}
|
||||
{hasPermission('can_access_locations') && (
|
||||
<SidebarItem
|
||||
to="/dashboard/locations"
|
||||
icon={MapPin}
|
||||
label={t('nav.locations', 'Locations')}
|
||||
isCollapsed={isCollapsed}
|
||||
locked={!canUse('multi_location')}
|
||||
/>
|
||||
)}
|
||||
</SidebarSection>
|
||||
)}
|
||||
@@ -245,7 +281,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
||||
)}
|
||||
|
||||
{/* Money Section - Payments */}
|
||||
{canViewAdminPages && (
|
||||
{hasPermission('can_access_payments') && (
|
||||
<SidebarSection title={t('nav.sections.money', 'Money')} isCollapsed={isCollapsed}>
|
||||
<SidebarItem
|
||||
to="/dashboard/payments"
|
||||
@@ -258,7 +294,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
||||
)}
|
||||
|
||||
{/* Extend Section - Automations */}
|
||||
{canViewAdminPages && (
|
||||
{hasPermission('can_access_automations') && (
|
||||
<SidebarSection title={t('nav.sections.extend', 'Extend')} isCollapsed={isCollapsed}>
|
||||
<SidebarItem
|
||||
to="/dashboard/automations/my-automations"
|
||||
|
||||
@@ -122,9 +122,11 @@ export const useIsAuthenticated = (): boolean => {
|
||||
/**
|
||||
* 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/';
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ export interface StaffInvitation {
|
||||
create_bookable_resource: boolean;
|
||||
resource_name: string;
|
||||
permissions: Record<string, boolean>;
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<string, boolean>;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
95
frontend/src/hooks/useStaffRoles.ts
Normal file
95
frontend/src/hooks/useStaffRoles.ts
Normal file
@@ -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<StaffRole[]>({
|
||||
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<StaffRole>({
|
||||
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<AvailablePermissions>({
|
||||
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<StaffRole>) => {
|
||||
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<StaffRole>) => {
|
||||
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'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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": {
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
AlertTriangle,
|
||||
Calendar,
|
||||
Clock,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
SettingsSidebarSection,
|
||||
@@ -154,6 +155,12 @@ const SettingsLayout: React.FC = () => {
|
||||
|
||||
{/* Access Section */}
|
||||
<SettingsSidebarSection title={t('settings.sections.access', 'Access')}>
|
||||
<SettingsSidebarItem
|
||||
to="/dashboard/settings/staff-roles"
|
||||
icon={Users}
|
||||
label={t('settings.staffRoles.title', 'Staff Roles')}
|
||||
description={t('settings.staffRoles.description', 'Role permissions')}
|
||||
/>
|
||||
<SettingsSidebarItem
|
||||
to="/dashboard/settings/authentication"
|
||||
icon={Lock}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
StaffInvitation,
|
||||
CreateInvitationData,
|
||||
} from '../hooks/useInvitations';
|
||||
import { useStaffRoles } from '../hooks/useStaffRoles';
|
||||
import {
|
||||
Plus,
|
||||
User as UserIcon,
|
||||
@@ -43,6 +44,7 @@ const Staff: React.FC<StaffProps> = ({ 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<StaffProps> = ({ 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<number | null>(null);
|
||||
const [createBookableResource, setCreateBookableResource] = useState(false);
|
||||
const [resourceName, setResourceName] = useState('');
|
||||
const [invitePermissions, setInvitePermissions] = useState<Record<string, boolean>>({});
|
||||
@@ -64,6 +67,7 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
const [editingStaff, setEditingStaff] = useState<StaffMember | null>(null);
|
||||
const [editPermissions, setEditPermissions] = useState<Record<string, boolean>>({});
|
||||
const [editStaffRoleId, setEditStaffRoleId] = useState<number | null>(null);
|
||||
const [editError, setEditError] = useState('');
|
||||
const [editSuccess, setEditSuccess] = useState('');
|
||||
|
||||
@@ -106,6 +110,7 @@ const Staff: React.FC<StaffProps> = ({ 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<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
||||
setCreateBookableResource(false);
|
||||
setResourceName('');
|
||||
setInvitePermissions({});
|
||||
setInviteStaffRoleId(null);
|
||||
// Close modal after short delay
|
||||
setTimeout(() => {
|
||||
setIsInviteModalOpen(false);
|
||||
@@ -146,6 +152,7 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
||||
const openInviteModal = () => {
|
||||
setInviteEmail('');
|
||||
setInviteRole('TENANT_STAFF');
|
||||
setInviteStaffRoleId(null);
|
||||
setCreateBookableResource(false);
|
||||
setResourceName('');
|
||||
setInvitePermissions({});
|
||||
@@ -190,6 +197,7 @@ const Staff: React.FC<StaffProps> = ({ 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<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
||||
setIsEditModalOpen(false);
|
||||
setEditingStaff(null);
|
||||
setEditPermissions({});
|
||||
setEditStaffRoleId(null);
|
||||
setEditError('');
|
||||
setEditSuccess('');
|
||||
};
|
||||
@@ -208,9 +217,16 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
||||
|
||||
setEditError('');
|
||||
try {
|
||||
const updates: { permissions: Record<string, boolean>; 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<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
||||
<div>
|
||||
<div className="font-medium text-gray-900 dark:text-white text-sm">{invitation.email}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{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()}
|
||||
</div>
|
||||
</div>
|
||||
@@ -309,6 +327,7 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
||||
<tr>
|
||||
<th className="px-6 py-4 font-medium">{t('staff.name')}</th>
|
||||
<th className="px-6 py-4 font-medium">{t('staff.role')}</th>
|
||||
<th className="px-6 py-4 font-medium">{t('staff.staffRole')}</th>
|
||||
<th className="px-6 py-4 font-medium">{t('staff.bookableResource')}</th>
|
||||
<th className="px-6 py-4 font-medium text-right">{t('common.actions')}</th>
|
||||
</tr>
|
||||
@@ -350,6 +369,21 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
||||
{user.role}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{user.role === 'staff' ? (
|
||||
user.staff_role_name ? (
|
||||
<span className="text-xs text-gray-700 dark:text-gray-300">
|
||||
{user.staff_role_name}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500 italic">
|
||||
{t('staff.noRoleAssigned')}
|
||||
</span>
|
||||
)
|
||||
) : (
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{linkedResource ? (
|
||||
<span className="inline-flex items-center gap-1 text-xs font-medium text-green-600 dark:text-green-400 bg-green-50 dark:bg-green-900/20 px-2 py-1 rounded">
|
||||
@@ -533,6 +567,30 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Staff Role Selector (only for staff invitations) */}
|
||||
{inviteRole === 'TENANT_STAFF' && staffRoles.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('staff.staffRole')}
|
||||
</label>
|
||||
<select
|
||||
value={inviteStaffRoleId ?? ''}
|
||||
onChange={(e) => setInviteStaffRoleId(e.target.value ? Number(e.target.value) : null)}
|
||||
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
|
||||
>
|
||||
<option value="">{t('staff.selectRole')}</option>
|
||||
{staffRoles.map((role) => (
|
||||
<option key={role.id} value={role.id}>
|
||||
{role.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{t('staff.staffRoleSelectHint')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Permissions - Using shared component */}
|
||||
{inviteRole === 'TENANT_MANAGER' && (
|
||||
<StaffPermissions
|
||||
@@ -672,6 +730,30 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Staff Role Selector (only for staff users) */}
|
||||
{editingStaff.role === 'staff' && staffRoles.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('staff.staffRole')}
|
||||
</label>
|
||||
<select
|
||||
value={editStaffRoleId ?? ''}
|
||||
onChange={(e) => setEditStaffRoleId(e.target.value ? Number(e.target.value) : null)}
|
||||
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
|
||||
>
|
||||
<option value="">{t('staff.selectRole')}</option>
|
||||
{staffRoles.map((role) => (
|
||||
<option key={role.id} value={role.id}>
|
||||
{role.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{t('staff.staffRoleSelectHint')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Permissions - Using shared component */}
|
||||
{editingStaff.role === 'manager' && (
|
||||
<StaffPermissions
|
||||
|
||||
483
frontend/src/pages/settings/StaffRolesSettings.tsx
Normal file
483
frontend/src/pages/settings/StaffRolesSettings.tsx
Normal file
@@ -0,0 +1,483 @@
|
||||
/**
|
||||
* Staff Roles Settings Page
|
||||
*
|
||||
* Create and manage staff roles with granular permissions.
|
||||
* Roles control what menu items and features are accessible to staff members.
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useOutletContext } from 'react-router-dom';
|
||||
import { Shield, Plus, X, Pencil, Trash2, Users, Lock, Check } from 'lucide-react';
|
||||
import { Business, User, StaffRole, PermissionDefinition } from '../../types';
|
||||
import {
|
||||
useStaffRoles,
|
||||
useAvailablePermissions,
|
||||
useCreateStaffRole,
|
||||
useUpdateStaffRole,
|
||||
useDeleteStaffRole,
|
||||
} from '../../hooks/useStaffRoles';
|
||||
|
||||
const StaffRolesSettings: React.FC = () => {
|
||||
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<StaffRole | null>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
permissions: {} as Record<string, boolean>,
|
||||
});
|
||||
const [error, setError] = useState<string | null>(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<string, boolean> = {};
|
||||
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<string, boolean>) => {
|
||||
return Object.values(permissions).filter(Boolean).length;
|
||||
};
|
||||
|
||||
if (!canManageRoles) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<Shield size={48} className="mx-auto mb-4 text-gray-300 dark:text-gray-600" />
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
{t('settings.staffRoles.noAccess', 'Only the business owner or manager can access these settings.')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-3">
|
||||
<Shield className="text-indigo-500" />
|
||||
{t('settings.staffRoles.title', 'Staff Roles')}
|
||||
</h2>
|
||||
<p className="text-gray-500 dark:text-gray-400 mt-1">
|
||||
{t('settings.staffRoles.subtitle', 'Create roles to control what staff members can access in your business.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Roles List */}
|
||||
<section className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{t('settings.staffRoles.yourRoles', 'Your Staff Roles')}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{t('settings.staffRoles.rolesDescription', 'Assign staff members to roles to control their permissions.')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={openCreateModal}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors font-medium text-sm"
|
||||
>
|
||||
<Plus size={16} />
|
||||
{t('settings.staffRoles.createRole', 'Create Role')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-brand-600"></div>
|
||||
</div>
|
||||
) : staffRoles.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
<Shield size={40} className="mx-auto mb-2 opacity-30" />
|
||||
<p>{t('settings.staffRoles.noRoles', 'No staff roles configured.')}</p>
|
||||
<p className="text-sm mt-1">{t('settings.staffRoles.createFirst', 'Create your first role to manage staff permissions.')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{staffRoles.map((role) => (
|
||||
<div
|
||||
key={role.id}
|
||||
className="p-4 bg-gray-50 dark:bg-gray-900/50 rounded-lg border border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-3 flex-1 min-w-0">
|
||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center shrink-0 ${
|
||||
role.is_default
|
||||
? 'bg-indigo-100 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400'
|
||||
: 'bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400'
|
||||
}`}>
|
||||
<Shield size={20} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-medium text-gray-900 dark:text-white flex items-center gap-2 flex-wrap">
|
||||
{role.name}
|
||||
{role.is_default && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-indigo-100 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400 rounded">
|
||||
{t('common.default', 'Default')}
|
||||
</span>
|
||||
)}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 flex items-center gap-3 mt-0.5">
|
||||
<span className="flex items-center gap-1">
|
||||
<Users size={14} />
|
||||
{t('settings.staffRoles.staffAssigned', '{{count}} staff', { count: role.staff_count })}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Check size={14} />
|
||||
{t('settings.staffRoles.permissionsEnabled', '{{count}} permissions', {
|
||||
count: countEnabledPermissions(role.permissions),
|
||||
})}
|
||||
</span>
|
||||
</p>
|
||||
{role.description && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 mt-1 line-clamp-2">
|
||||
{role.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0 ml-2">
|
||||
<button
|
||||
onClick={() => openEditModal(role)}
|
||||
className="p-2 text-gray-400 hover:text-brand-600 dark:hover:text-brand-400 transition-colors"
|
||||
title={t('common.edit', 'Edit')}
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(role)}
|
||||
disabled={deleteStaffRole.isPending || !role.can_delete}
|
||||
className={`p-2 transition-colors disabled:opacity-50 ${
|
||||
role.can_delete
|
||||
? 'text-gray-400 hover:text-red-600 dark:hover:text-red-400'
|
||||
: 'text-gray-300 dark:text-gray-600 cursor-not-allowed'
|
||||
}`}
|
||||
title={
|
||||
role.is_default
|
||||
? t('settings.staffRoles.cannotDeleteDefault', 'Default roles cannot be deleted')
|
||||
: role.staff_count > 0
|
||||
? t('settings.staffRoles.cannotDeleteInUse', 'Remove all staff first')
|
||||
: t('common.delete', 'Delete')
|
||||
}
|
||||
>
|
||||
{role.can_delete ? <Trash2 size={16} /> : <Lock size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-2xl w-full max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-100 dark:border-gray-700 shrink-0">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{editingRole
|
||||
? t('settings.staffRoles.editRole', 'Edit Role')
|
||||
: t('settings.staffRoles.createRole', 'Create Role')}
|
||||
</h3>
|
||||
<button
|
||||
onClick={closeModal}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex-1 overflow-y-auto">
|
||||
<div className="p-6 space-y-6">
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 rounded-lg text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Basic Info */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('settings.staffRoles.roleName', 'Role Name')} *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => 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')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('settings.staffRoles.roleDescription', 'Description')}
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
rows={2}
|
||||
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 resize-none"
|
||||
placeholder={t('settings.staffRoles.roleDescriptionPlaceholder', 'Describe what this role can do...')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Menu Permissions */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{t('settings.staffRoles.menuPermissions', 'Menu Access')}
|
||||
</h4>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{t('settings.staffRoles.menuPermissionsDescription', 'Control which pages staff can see in the sidebar.')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleAllPermissions('menu', true)}
|
||||
className="text-xs text-brand-600 dark:text-brand-400 hover:underline"
|
||||
>
|
||||
{t('common.selectAll', 'Select All')}
|
||||
</button>
|
||||
<span className="text-gray-300 dark:text-gray-600">|</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleAllPermissions('menu', false)}
|
||||
className="text-xs text-gray-500 dark:text-gray-400 hover:underline"
|
||||
>
|
||||
{t('common.clearAll', 'Clear All')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{Object.entries(allPermissions.menu).map(([key, def]: [string, PermissionDefinition]) => (
|
||||
<label
|
||||
key={key}
|
||||
className="flex items-center gap-2 p-2 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.permissions[key] || false}
|
||||
onChange={() => togglePermission(key)}
|
||||
className="w-4 h-4 text-brand-600 border-gray-300 dark:border-gray-600 rounded focus:ring-brand-500"
|
||||
/>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{def.label}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{def.description}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dangerous Permissions */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
{t('settings.staffRoles.dangerousPermissions', 'Dangerous Operations')}
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400 rounded">
|
||||
{t('common.caution', 'Caution')}
|
||||
</span>
|
||||
</h4>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{t('settings.staffRoles.dangerousPermissionsDescription', 'Allow staff to perform destructive or sensitive actions.')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleAllPermissions('dangerous', true)}
|
||||
className="text-xs text-brand-600 dark:text-brand-400 hover:underline"
|
||||
>
|
||||
{t('common.selectAll', 'Select All')}
|
||||
</button>
|
||||
<span className="text-gray-300 dark:text-gray-600">|</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleAllPermissions('dangerous', false)}
|
||||
className="text-xs text-gray-500 dark:text-gray-400 hover:underline"
|
||||
>
|
||||
{t('common.clearAll', 'Clear All')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 p-3 bg-red-50/50 dark:bg-red-900/10 rounded-lg border border-red-100 dark:border-red-900/30">
|
||||
{Object.entries(allPermissions.dangerous).map(([key, def]: [string, PermissionDefinition]) => (
|
||||
<label
|
||||
key={key}
|
||||
className="flex items-center gap-2 p-2 rounded-lg hover:bg-red-100/50 dark:hover:bg-red-900/20 cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.permissions[key] || false}
|
||||
onChange={() => togglePermission(key)}
|
||||
className="w-4 h-4 text-red-600 border-gray-300 dark:border-gray-600 rounded focus:ring-red-500"
|
||||
/>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{def.label}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{def.description}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 p-6 border-t border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
className="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createStaffRole.isPending || updateStaffRole.isPending}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{editingRole ? t('common.save', 'Save') : t('common.create', 'Create')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StaffRolesSettings;
|
||||
@@ -135,6 +135,34 @@ export interface User {
|
||||
linked_resource_name?: string;
|
||||
permissions?: Record<string, boolean>;
|
||||
quota_overages?: QuotaOverage[];
|
||||
// Staff role fields
|
||||
staff_role_id?: number | null;
|
||||
staff_role_name?: string | null;
|
||||
effective_permissions?: Record<string, boolean>;
|
||||
}
|
||||
|
||||
// Staff Role Types
|
||||
export interface StaffRole {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
permissions: Record<string, boolean>;
|
||||
is_default: boolean;
|
||||
staff_count: number;
|
||||
can_delete: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface PermissionDefinition {
|
||||
label: string;
|
||||
description: string;
|
||||
default: boolean;
|
||||
}
|
||||
|
||||
export interface AvailablePermissions {
|
||||
menu_permissions: Record<string, PermissionDefinition>;
|
||||
dangerous_permissions: Record<string, PermissionDefinition>;
|
||||
}
|
||||
|
||||
export type ResourceType = 'STAFF' | 'ROOM' | 'EQUIPMENT';
|
||||
|
||||
Reference in New Issue
Block a user