Improve staff management UI and add sorting functionality

- Remove WIP badge from staff sidebar navigation
- Make action buttons consistent between Customers and Staff pages
  - Edit button: icon + text with gray border
  - Masquerade button: icon + text with indigo border
  - Verify email button: icon-only with colored border (green/amber)
- Add sortable columns to Staff list (name and role)
- Include migrations for tenant manager role removal

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
poduck
2025-12-17 19:29:13 -05:00
parent a80b35a806
commit 92019aac7e
68 changed files with 1827 additions and 788 deletions

View File

@@ -55,18 +55,18 @@ const testUsers: TestUser[] = [
category: 'business',
},
{
email: 'manager@demo.com',
email: 'staff@demo.com',
password: 'test123',
role: 'TENANT_MANAGER',
label: 'Business Manager',
role: 'TENANT_STAFF',
label: 'Staff (Full Access)',
color: 'bg-pink-600 hover:bg-pink-700',
category: 'business',
},
{
email: 'staff@demo.com',
email: 'limited-staff@demo.com',
password: 'test123',
role: 'TENANT_STAFF',
label: 'Staff Member',
label: 'Staff (Limited)',
color: 'bg-teal-600 hover:bg-teal-700',
category: 'business',
},

View File

@@ -46,10 +46,10 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
const { canUse } = usePlanFeatures();
// Helper to check if user has a specific staff permission
// Owners and managers always have all permissions
// Owners always have all permissions
// Staff members check their effective_permissions (role + user overrides)
const hasPermission = (permissionKey: string): boolean => {
if (role === 'owner' || role === 'manager') {
if (role === 'owner') {
return true;
}
if (role === 'staff') {
@@ -59,10 +59,11 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
return false;
};
const canViewAdminPages = role === 'owner' || role === 'manager';
const canViewManagementPages = role === 'owner' || role === 'manager';
// Admin/management access is based on effective permissions for staff
const canViewAdminPages = role === 'owner' || hasPermission('can_access_staff');
const canViewManagementPages = role === 'owner' || hasPermission('can_access_scheduler');
const isStaff = role === 'staff';
const canViewSettings = role === 'owner';
const canViewSettings = role === 'owner' || hasPermission('can_access_settings');
const canViewTickets = hasPermission('can_access_tickets');
const canSendMessages = hasPermission('can_access_messages') || user.can_send_messages === true;
@@ -191,7 +192,6 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
icon={Users}
label={t('nav.customers')}
isCollapsed={isCollapsed}
badgeElement={<UnfinishedBadge />}
/>
)}
{hasPermission('can_access_services') && (
@@ -216,7 +216,6 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
icon={Users}
label={t('nav.staff')}
isCollapsed={isCollapsed}
badgeElement={<UnfinishedBadge />}
/>
)}
{hasPermission('can_access_contracts') && canUse('contracts') && (

View File

@@ -1,5 +1,6 @@
import React from 'react';
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { ChevronDown, ChevronRight } from 'lucide-react';
export interface PermissionConfig {
key: string;
@@ -8,20 +9,134 @@ export interface PermissionConfig {
hintKey: string;
hintDefault: string;
defaultValue: boolean;
roles: ('manager' | 'staff')[];
}
// Business Settings sub-permissions
export const SETTINGS_PERMISSION_CONFIGS: PermissionConfig[] = [
{
key: 'can_access_settings_general',
labelKey: 'staff.canAccessSettingsGeneral',
labelDefault: 'General Settings',
hintKey: 'staff.canAccessSettingsGeneralHint',
hintDefault: 'Business name, timezone, and basic configuration',
defaultValue: false,
},
{
key: 'can_access_settings_business_hours',
labelKey: 'staff.canAccessSettingsBusinessHours',
labelDefault: 'Business Hours',
hintKey: 'staff.canAccessSettingsBusinessHoursHint',
hintDefault: 'Set regular operating hours',
defaultValue: false,
},
{
key: 'can_access_settings_branding',
labelKey: 'staff.canAccessSettingsBranding',
labelDefault: 'Branding',
hintKey: 'staff.canAccessSettingsBrandingHint',
hintDefault: 'Logo, colors, and visual identity',
defaultValue: false,
},
{
key: 'can_access_settings_booking',
labelKey: 'staff.canAccessSettingsBooking',
labelDefault: 'Booking Settings',
hintKey: 'staff.canAccessSettingsBookingHint',
hintDefault: 'Booking policies and rules',
defaultValue: false,
},
{
key: 'can_access_settings_communication',
labelKey: 'staff.canAccessSettingsCommunication',
labelDefault: 'Communication',
hintKey: 'staff.canAccessSettingsCommunicationHint',
hintDefault: 'Notification preferences and reminders',
defaultValue: false,
},
{
key: 'can_access_settings_embed_widget',
labelKey: 'staff.canAccessSettingsEmbedWidget',
labelDefault: 'Embed Widget',
hintKey: 'staff.canAccessSettingsEmbedWidgetHint',
hintDefault: 'Configure booking widget for websites',
defaultValue: false,
},
{
key: 'can_access_settings_email_templates',
labelKey: 'staff.canAccessSettingsEmailTemplates',
labelDefault: 'Email Templates',
hintKey: 'staff.canAccessSettingsEmailTemplatesHint',
hintDefault: 'Customize automated emails',
defaultValue: false,
},
{
key: 'can_access_settings_staff_roles',
labelKey: 'staff.canAccessSettingsStaffRoles',
labelDefault: 'Staff Roles',
hintKey: 'staff.canAccessSettingsStaffRolesHint',
hintDefault: 'Create and manage permission roles',
defaultValue: false,
},
{
key: 'can_access_settings_resource_types',
labelKey: 'staff.canAccessSettingsResourceTypes',
labelDefault: 'Resource Types',
hintKey: 'staff.canAccessSettingsResourceTypesHint',
hintDefault: 'Configure resource categories',
defaultValue: false,
},
{
key: 'can_access_settings_api',
labelKey: 'staff.canAccessSettingsApi',
labelDefault: 'API & Integrations',
hintKey: 'staff.canAccessSettingsApiHint',
hintDefault: 'Manage API tokens and webhooks',
defaultValue: false,
},
{
key: 'can_access_settings_custom_domains',
labelKey: 'staff.canAccessSettingsCustomDomains',
labelDefault: 'Custom Domains',
hintKey: 'staff.canAccessSettingsCustomDomainsHint',
hintDefault: 'Configure custom domain settings',
defaultValue: false,
},
{
key: 'can_access_settings_authentication',
labelKey: 'staff.canAccessSettingsAuthentication',
labelDefault: 'Authentication',
hintKey: 'staff.canAccessSettingsAuthenticationHint',
hintDefault: 'OAuth and social login configuration',
defaultValue: false,
},
{
key: 'can_access_settings_email',
labelKey: 'staff.canAccessSettingsEmail',
labelDefault: 'Email Setup',
hintKey: 'staff.canAccessSettingsEmailHint',
hintDefault: 'Configure email addresses for tickets',
defaultValue: false,
},
{
key: 'can_access_settings_sms_calling',
labelKey: 'staff.canAccessSettingsSmsCalling',
labelDefault: 'SMS & Calling',
hintKey: 'staff.canAccessSettingsSmsCallingHint',
hintDefault: 'Manage credits and phone numbers',
defaultValue: false,
},
];
// Define all available permissions in one place
// All permissions are now available to staff (via staff roles)
export const PERMISSION_CONFIGS: PermissionConfig[] = [
// Manager-only permissions
{
key: 'can_invite_staff',
labelKey: 'staff.canInviteStaff',
labelDefault: 'Can invite new staff members',
hintKey: 'staff.canInviteStaffHint',
hintDefault: 'Allow this manager to send invitations to new staff members',
hintDefault: 'Allow this staff member to send invitations to new staff members',
defaultValue: false,
roles: ['manager'],
},
{
key: 'can_manage_resources',
@@ -29,8 +144,7 @@ export const PERMISSION_CONFIGS: PermissionConfig[] = [
labelDefault: 'Can manage resources',
hintKey: 'staff.canManageResourcesHint',
hintDefault: 'Create, edit, and delete bookable resources',
defaultValue: true,
roles: ['manager'],
defaultValue: false,
},
{
key: 'can_manage_services',
@@ -38,8 +152,7 @@ export const PERMISSION_CONFIGS: PermissionConfig[] = [
labelDefault: 'Can manage services',
hintKey: 'staff.canManageServicesHint',
hintDefault: 'Create, edit, and delete service offerings',
defaultValue: true,
roles: ['manager'],
defaultValue: false,
},
{
key: 'can_view_reports',
@@ -47,17 +160,7 @@ export const PERMISSION_CONFIGS: PermissionConfig[] = [
labelDefault: 'Can view reports',
hintKey: 'staff.canViewReportsHint',
hintDefault: 'Access business analytics and financial reports',
defaultValue: true,
roles: ['manager'],
},
{
key: 'can_access_settings',
labelKey: 'staff.canAccessSettings',
labelDefault: 'Can access business settings',
hintKey: 'staff.canAccessSettingsHint',
hintDefault: 'Modify business profile, branding, and configuration',
defaultValue: false,
roles: ['manager'],
},
{
key: 'can_refund_payments',
@@ -66,7 +169,6 @@ export const PERMISSION_CONFIGS: PermissionConfig[] = [
hintKey: 'staff.canRefundPaymentsHint',
hintDefault: 'Process refunds for customer payments',
defaultValue: false,
roles: ['manager'],
},
{
key: 'can_send_messages',
@@ -74,10 +176,8 @@ export const PERMISSION_CONFIGS: PermissionConfig[] = [
labelDefault: 'Can send broadcast messages',
hintKey: 'staff.canSendMessagesHint',
hintDefault: 'Send messages to groups of staff and customers',
defaultValue: true,
roles: ['manager'],
defaultValue: false,
},
// Staff-only permissions
{
key: 'can_view_all_schedules',
labelKey: 'staff.canViewAllSchedules',
@@ -85,7 +185,6 @@ export const PERMISSION_CONFIGS: PermissionConfig[] = [
hintKey: 'staff.canViewAllSchedulesHint',
hintDefault: 'View schedules of other staff members (otherwise only their own)',
defaultValue: false,
roles: ['staff'],
},
{
key: 'can_manage_own_appointments',
@@ -94,112 +193,132 @@ export const PERMISSION_CONFIGS: PermissionConfig[] = [
hintKey: 'staff.canManageOwnAppointmentsHint',
hintDefault: 'Create, reschedule, and cancel their own appointments',
defaultValue: true,
roles: ['staff'],
},
{
key: 'can_self_approve_time_off',
labelKey: 'staff.canSelfApproveTimeOff',
labelDefault: 'Can self-approve time off',
hintKey: 'staff.canSelfApproveTimeOffHint',
hintDefault: 'Add time off without requiring manager/owner approval',
hintDefault: 'Add time off without requiring owner approval',
defaultValue: false,
roles: ['staff'],
},
// Shared permissions (both manager and staff)
{
key: 'can_access_tickets',
labelKey: 'staff.canAccessTickets',
labelDefault: 'Can access support tickets',
hintKey: 'staff.canAccessTicketsHint',
hintDefault: 'View and manage customer support tickets',
defaultValue: true, // Default for managers; staff will override to false
roles: ['manager', 'staff'],
defaultValue: false,
},
];
// Get default permissions for a role
export const getDefaultPermissions = (role: 'manager' | 'staff'): Record<string, boolean> => {
// Get default permissions for staff
export const getDefaultPermissions = (): Record<string, boolean> => {
const defaults: Record<string, boolean> = {};
PERMISSION_CONFIGS.forEach((config) => {
if (config.roles.includes(role)) {
// Staff members have ticket access disabled by default
if (role === 'staff' && config.key === 'can_access_tickets') {
defaults[config.key] = false;
} else {
defaults[config.key] = config.defaultValue;
}
}
defaults[config.key] = config.defaultValue;
});
SETTINGS_PERMISSION_CONFIGS.forEach((config) => {
defaults[config.key] = config.defaultValue;
});
defaults['can_access_settings'] = false;
return defaults;
};
interface StaffPermissionsProps {
role: 'manager' | 'staff';
role: 'staff';
permissions: Record<string, boolean>;
onChange: (permissions: Record<string, boolean>) => void;
variant?: 'invite' | 'edit';
}
const StaffPermissions: React.FC<StaffPermissionsProps> = ({
role,
permissions,
onChange,
variant = 'edit',
}) => {
const { t } = useTranslation();
// Filter permissions for this role
const rolePermissions = PERMISSION_CONFIGS.filter((config) =>
config.roles.includes(role)
);
const handleToggle = (key: string, checked: boolean) => {
onChange({ ...permissions, [key]: checked });
};
const [settingsExpanded, setSettingsExpanded] = useState(false);
// Get the current value, falling back to default
const getValue = (config: PermissionConfig): boolean => {
if (permissions[config.key] !== undefined) {
return permissions[config.key];
const getValue = (key: string, defaultValue: boolean = false): boolean => {
if (permissions[key] !== undefined) {
return permissions[key];
}
// Staff have ticket access disabled by default
if (role === 'staff' && config.key === 'can_access_tickets') {
return false;
}
return config.defaultValue;
return defaultValue;
};
// Different styling for manager vs staff permissions
const isManagerPermission = (config: PermissionConfig) =>
config.roles.includes('manager') && !config.roles.includes('staff');
const hasSettingsAccess = getValue('can_access_settings', false);
const getPermissionStyle = (config: PermissionConfig) => {
if (isManagerPermission(config) || role === 'manager') {
return 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800 hover:bg-blue-100 dark:hover:bg-blue-900/30';
// Auto-expand settings section if any settings permissions are enabled
useEffect(() => {
if (hasSettingsAccess) {
const hasAnySettingEnabled = SETTINGS_PERMISSION_CONFIGS.some(
(config) => getValue(config.key, false)
);
if (hasAnySettingEnabled) {
setSettingsExpanded(true);
}
}
return 'bg-gray-50 dark:bg-gray-700/50 border-gray-200 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700';
}, []);
const handleToggle = (key: string, checked: boolean) => {
const newPermissions = { ...permissions, [key]: checked };
// If turning off main settings access, turn off all sub-settings
if (key === 'can_access_settings' && !checked) {
SETTINGS_PERMISSION_CONFIGS.forEach((config) => {
newPermissions[config.key] = false;
});
}
onChange(newPermissions);
};
if (rolePermissions.length === 0) {
return null;
}
const handleSettingsMainToggle = (checked: boolean) => {
const newPermissions = { ...permissions, can_access_settings: checked };
// If turning off, disable all sub-settings
if (!checked) {
SETTINGS_PERMISSION_CONFIGS.forEach((config) => {
newPermissions[config.key] = false;
});
setSettingsExpanded(false);
} else {
// If turning on, expand the section
setSettingsExpanded(true);
}
onChange(newPermissions);
};
const handleSelectAllSettings = (selectAll: boolean) => {
const newPermissions = { ...permissions };
SETTINGS_PERMISSION_CONFIGS.forEach((config) => {
newPermissions[config.key] = selectAll;
});
onChange(newPermissions);
};
// Count how many settings sub-permissions are enabled
const enabledSettingsCount = SETTINGS_PERMISSION_CONFIGS.filter((config) =>
getValue(config.key, false)
).length;
return (
<div className="space-y-3">
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300">
{role === 'manager'
? t('staff.managerPermissions', 'Manager Permissions')
: t('staff.staffPermissions', 'Staff Permissions')}
{t('staff.staffPermissions', 'Staff Permissions')}
</h4>
{rolePermissions.map((config) => (
{/* Regular permissions */}
{PERMISSION_CONFIGS.map((config) => (
<label
key={config.key}
className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${getPermissionStyle(config)}`}
className="flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors bg-gray-50 dark:bg-gray-700/50 border-gray-200 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700"
>
<input
type="checkbox"
checked={getValue(config)}
checked={getValue(config.key, config.defaultValue)}
onChange={(e) => handleToggle(config.key, e.target.checked)}
className="w-4 h-4 mt-0.5 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
/>
@@ -213,6 +332,113 @@ const StaffPermissions: React.FC<StaffPermissionsProps> = ({
</div>
</label>
))}
{/* Business Settings Section */}
<div className="border rounded-lg border-gray-200 dark:border-gray-600 overflow-hidden">
{/* Main Business Settings Toggle */}
<div
className={`flex items-start gap-3 p-3 cursor-pointer transition-colors ${
hasSettingsAccess
? 'bg-brand-50 dark:bg-brand-900/20'
: 'bg-gray-50 dark:bg-gray-700/50 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
>
<input
type="checkbox"
checked={hasSettingsAccess}
onChange={(e) => handleSettingsMainToggle(e.target.checked)}
className="w-4 h-4 mt-0.5 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
/>
<div
className="flex-1"
onClick={() => hasSettingsAccess && setSettingsExpanded(!settingsExpanded)}
>
<div className="flex items-center justify-between">
<div>
<span className="text-sm font-medium text-gray-900 dark:text-white">
{t('staff.canAccessSettings', 'Can access business settings')}
</span>
{hasSettingsAccess && enabledSettingsCount > 0 && (
<span className="ml-2 text-xs text-brand-600 dark:text-brand-400">
({enabledSettingsCount}/{SETTINGS_PERMISSION_CONFIGS.length} enabled)
</span>
)}
</div>
{hasSettingsAccess && (
<button
type="button"
className="p-1 hover:bg-gray-200 dark:hover:bg-gray-600 rounded"
onClick={(e) => {
e.stopPropagation();
setSettingsExpanded(!settingsExpanded);
}}
>
{settingsExpanded ? (
<ChevronDown size={16} className="text-gray-500" />
) : (
<ChevronRight size={16} className="text-gray-500" />
)}
</button>
)}
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{t(
'staff.canAccessSettingsHint',
'Access to business settings pages (select specific pages below)'
)}
</p>
</div>
</div>
{/* Sub-permissions (collapsible) */}
{hasSettingsAccess && settingsExpanded && (
<div className="border-t border-gray-200 dark:border-gray-600 bg-gray-25 dark:bg-gray-800/50">
{/* Select All / None buttons */}
<div className="px-3 py-2 border-b border-gray-200 dark:border-gray-600 flex gap-2">
<button
type="button"
onClick={() => handleSelectAllSettings(true)}
className="text-xs text-brand-600 hover:text-brand-700 dark:text-brand-400 dark:hover:text-brand-300 font-medium"
>
{t('staff.selectAll', 'Select All')}
</button>
<span className="text-gray-300 dark:text-gray-600">|</span>
<button
type="button"
onClick={() => handleSelectAllSettings(false)}
className="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 font-medium"
>
{t('staff.selectNone', 'Select None')}
</button>
</div>
{/* Individual settings permissions */}
<div className="divide-y divide-gray-100 dark:divide-gray-700">
{SETTINGS_PERMISSION_CONFIGS.map((config) => (
<label
key={config.key}
className="flex items-start gap-3 px-3 py-2.5 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700/50 transition-colors"
>
<input
type="checkbox"
checked={getValue(config.key, config.defaultValue)}
onChange={(e) => handleToggle(config.key, e.target.checked)}
className="w-4 h-4 mt-0.5 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
/>
<div>
<span className="text-sm font-medium text-gray-900 dark:text-white">
{t(config.labelKey, config.labelDefault)}
</span>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{t(config.hintKey, config.hintDefault)}
</p>
</div>
</label>
))}
</div>
</div>
)}
</div>
</div>
);
};

View File

@@ -50,7 +50,7 @@ describe('MasqueradeBanner', () => {
it('shows return to previous user text when previousUser exists', () => {
const propsWithPrevious = {
...defaultProps,
previousUser: { id: '3', name: 'Manager', email: 'manager@test.com', role: 'manager' as const },
previousUser: { id: '3', name: 'Manager', email: 'manager@test.com', role: 'owner' as const },
};
render(<MasqueradeBanner {...propsWithPrevious} />);
expect(screen.getByText(/platform.masquerade.returnTo/)).toBeInTheDocument();

View File

@@ -518,8 +518,8 @@ describe('TopBar', () => {
expect(screen.getByTestId('user-profile-dropdown')).toBeInTheDocument();
});
it('should render for manager role', () => {
const user = createMockUser({ role: 'manager' });
it('should render for staff with permissions', () => {
const user = createMockUser({ role: 'staff' });
renderWithRouter(
<TopBar