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:
@@ -456,7 +456,7 @@ const AppContent: React.FC = () => {
|
||||
const isBusinessSubdomain = !isRootDomain() && !isPlatformDomain && currentSubdomain !== 'api' && currentHostname !== baseDomain;
|
||||
|
||||
const isPlatformUser = ['superuser', 'platform_manager', 'platform_support'].includes(user.role);
|
||||
const isBusinessUser = ['owner', 'manager', 'staff', 'resource'].includes(user.role);
|
||||
const isBusinessUser = ['owner', 'staff', 'resource'].includes(user.role);
|
||||
const isCustomer = user.role === 'customer';
|
||||
|
||||
// RULE: Platform users on business subdomains should be redirected to platform subdomain
|
||||
@@ -510,6 +510,15 @@ const AppContent: React.FC = () => {
|
||||
// Helper to check access based on roles
|
||||
const hasAccess = (allowedRoles: string[]) => allowedRoles.includes(user.role);
|
||||
|
||||
// Helper to check permission-based access (owner always has access, staff uses effective_permissions)
|
||||
const canAccess = (permissionKey: string): boolean => {
|
||||
if (user.role === 'owner') return true;
|
||||
if (user.role === 'staff') {
|
||||
return user.effective_permissions?.[permissionKey] === true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
if (isPlatformUser) {
|
||||
return (
|
||||
<Suspense fallback={<LoadingScreen />}>
|
||||
@@ -658,8 +667,8 @@ const AppContent: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
// Business users (owner, manager, staff, resource)
|
||||
if (['owner', 'manager', 'staff', 'resource'].includes(user.role)) {
|
||||
// Business users (owner, staff, resource)
|
||||
if (['owner', 'staff', 'resource'].includes(user.role)) {
|
||||
// Check if email verification is required
|
||||
if (!user.email_verified) {
|
||||
return (
|
||||
@@ -799,7 +808,7 @@ const AppContent: React.FC = () => {
|
||||
<Route
|
||||
path="/dashboard/automations/marketplace"
|
||||
element={
|
||||
hasAccess(['owner', 'manager']) ? (
|
||||
canAccess('can_access_automations') ? (
|
||||
<AutomationMarketplace />
|
||||
) : (
|
||||
<Navigate to="/dashboard" />
|
||||
@@ -809,7 +818,7 @@ const AppContent: React.FC = () => {
|
||||
<Route
|
||||
path="/dashboard/automations/my-automations"
|
||||
element={
|
||||
hasAccess(['owner', 'manager']) ? (
|
||||
canAccess('can_access_automations') ? (
|
||||
<MyAutomations />
|
||||
) : (
|
||||
<Navigate to="/dashboard" />
|
||||
@@ -819,7 +828,7 @@ const AppContent: React.FC = () => {
|
||||
<Route
|
||||
path="/dashboard/automations/create"
|
||||
element={
|
||||
hasAccess(['owner', 'manager']) ? (
|
||||
canAccess('can_access_automations') ? (
|
||||
<CreateAutomation />
|
||||
) : (
|
||||
<Navigate to="/dashboard" />
|
||||
@@ -829,7 +838,7 @@ const AppContent: React.FC = () => {
|
||||
<Route
|
||||
path="/dashboard/tasks"
|
||||
element={
|
||||
hasAccess(['owner', 'manager']) ? (
|
||||
canAccess('can_access_tasks') ? (
|
||||
<Tasks />
|
||||
) : (
|
||||
<Navigate to="/dashboard" />
|
||||
@@ -841,7 +850,7 @@ const AppContent: React.FC = () => {
|
||||
<Route
|
||||
path="/dashboard/customers"
|
||||
element={
|
||||
hasAccess(['owner', 'manager']) ? (
|
||||
canAccess('can_access_customers') ? (
|
||||
<Customers onMasquerade={handleMasquerade} effectiveUser={user} />
|
||||
) : (
|
||||
<Navigate to="/dashboard" />
|
||||
@@ -851,7 +860,7 @@ const AppContent: React.FC = () => {
|
||||
<Route
|
||||
path="/dashboard/services"
|
||||
element={
|
||||
hasAccess(['owner', 'manager']) ? (
|
||||
canAccess('can_access_services') ? (
|
||||
<Services />
|
||||
) : (
|
||||
<Navigate to="/dashboard" />
|
||||
@@ -861,7 +870,7 @@ const AppContent: React.FC = () => {
|
||||
<Route
|
||||
path="/dashboard/resources"
|
||||
element={
|
||||
hasAccess(['owner', 'manager']) ? (
|
||||
canAccess('can_access_resources') ? (
|
||||
<Resources onMasquerade={handleMasquerade} effectiveUser={user} />
|
||||
) : (
|
||||
<Navigate to="/dashboard" />
|
||||
@@ -871,7 +880,7 @@ const AppContent: React.FC = () => {
|
||||
<Route
|
||||
path="/dashboard/staff"
|
||||
element={
|
||||
hasAccess(['owner', 'manager']) ? (
|
||||
canAccess('can_access_staff') ? (
|
||||
<Staff onMasquerade={handleMasquerade} effectiveUser={user} />
|
||||
) : (
|
||||
<Navigate to="/dashboard" />
|
||||
@@ -881,7 +890,7 @@ const AppContent: React.FC = () => {
|
||||
<Route
|
||||
path="/dashboard/time-blocks"
|
||||
element={
|
||||
hasAccess(['owner', 'manager']) ? (
|
||||
canAccess('can_access_time_blocks') ? (
|
||||
<TimeBlocks />
|
||||
) : (
|
||||
<Navigate to="/dashboard" />
|
||||
@@ -891,7 +900,7 @@ const AppContent: React.FC = () => {
|
||||
<Route
|
||||
path="/dashboard/locations"
|
||||
element={
|
||||
hasAccess(['owner', 'manager']) ? (
|
||||
canAccess('can_access_locations') ? (
|
||||
<Locations />
|
||||
) : (
|
||||
<Navigate to="/dashboard" />
|
||||
@@ -911,7 +920,7 @@ const AppContent: React.FC = () => {
|
||||
<Route
|
||||
path="/dashboard/contracts"
|
||||
element={
|
||||
hasAccess(['owner', 'manager']) && canUse('contracts') ? (
|
||||
canAccess('can_access_contracts') && canUse('contracts') ? (
|
||||
<Contracts />
|
||||
) : (
|
||||
<Navigate to="/dashboard" />
|
||||
@@ -921,7 +930,7 @@ const AppContent: React.FC = () => {
|
||||
<Route
|
||||
path="/dashboard/contracts/templates"
|
||||
element={
|
||||
hasAccess(['owner', 'manager']) && canUse('contracts') ? (
|
||||
canAccess('can_access_contracts') && canUse('contracts') ? (
|
||||
<ContractTemplates />
|
||||
) : (
|
||||
<Navigate to="/dashboard" />
|
||||
@@ -931,13 +940,13 @@ const AppContent: React.FC = () => {
|
||||
<Route
|
||||
path="/dashboard/payments"
|
||||
element={
|
||||
hasAccess(['owner', 'manager']) ? <Payments /> : <Navigate to="/dashboard" />
|
||||
canAccess('can_access_payments') ? <Payments /> : <Navigate to="/dashboard" />
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/dashboard/messages"
|
||||
element={
|
||||
hasAccess(['owner', 'manager']) && user?.can_send_messages ? (
|
||||
canAccess('can_access_messages') ? (
|
||||
<Messages />
|
||||
) : (
|
||||
<Navigate to="/dashboard" />
|
||||
@@ -947,7 +956,7 @@ const AppContent: React.FC = () => {
|
||||
<Route
|
||||
path="/dashboard/site-editor"
|
||||
element={
|
||||
hasAccess(['owner', 'manager']) ? (
|
||||
canAccess('can_access_site_editor') ? (
|
||||
<PageEditor />
|
||||
) : (
|
||||
<Navigate to="/dashboard" />
|
||||
@@ -967,7 +976,7 @@ const AppContent: React.FC = () => {
|
||||
<Route
|
||||
path="/dashboard/gallery"
|
||||
element={
|
||||
hasAccess(['owner', 'manager']) ? (
|
||||
canAccess('can_access_gallery') ? (
|
||||
<MediaGalleryPage />
|
||||
) : (
|
||||
<Navigate to="/dashboard" />
|
||||
@@ -975,7 +984,8 @@ const AppContent: React.FC = () => {
|
||||
}
|
||||
/>
|
||||
{/* Settings Routes with Nested Layout */}
|
||||
{hasAccess(['owner']) ? (
|
||||
{/* Owners have full access, staff need can_access_settings permission */}
|
||||
{canAccess('can_access_settings') ? (
|
||||
<Route path="/dashboard/settings" element={<SettingsLayout />}>
|
||||
<Route index element={<Navigate to="/dashboard/settings/general" replace />} />
|
||||
<Route path="general" element={<GeneralSettings />} />
|
||||
|
||||
@@ -71,6 +71,9 @@ export interface User {
|
||||
business_name?: string;
|
||||
business_subdomain?: string;
|
||||
permissions?: Record<string, boolean>;
|
||||
effective_permissions?: Record<string, boolean>;
|
||||
staff_role_id?: number | null;
|
||||
staff_role_name?: string | null;
|
||||
can_invite_staff?: boolean;
|
||||
can_access_tickets?: boolean;
|
||||
can_edit_schedule?: boolean;
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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') && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -51,8 +51,8 @@ describe('useInvitations hooks', () => {
|
||||
{
|
||||
id: 1,
|
||||
email: 'john@example.com',
|
||||
role: 'TENANT_MANAGER',
|
||||
role_display: 'Manager',
|
||||
role: 'TENANT_STAFF',
|
||||
role_display: 'Staff',
|
||||
status: 'PENDING',
|
||||
invited_by: 5,
|
||||
invited_by_name: 'Admin User',
|
||||
@@ -205,10 +205,10 @@ describe('useInvitations hooks', () => {
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/staff/invitations/', invitationData);
|
||||
});
|
||||
|
||||
it('creates manager invitation with permissions', async () => {
|
||||
it('creates staff invitation with permissions', async () => {
|
||||
const invitationData: CreateInvitationData = {
|
||||
email: 'manager@example.com',
|
||||
role: 'TENANT_MANAGER',
|
||||
email: 'staff@example.com',
|
||||
role: 'TENANT_STAFF',
|
||||
permissions: {
|
||||
can_invite_staff: true,
|
||||
can_manage_resources: true,
|
||||
@@ -219,7 +219,7 @@ describe('useInvitations hooks', () => {
|
||||
},
|
||||
};
|
||||
|
||||
const mockResponse = { id: 5, email: 'manager@example.com', role: 'TENANT_MANAGER' };
|
||||
const mockResponse = { id: 5, email: 'staff@example.com', role: 'TENANT_STAFF' };
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const { result } = renderHook(() => useCreateInvitation(), {
|
||||
|
||||
@@ -46,7 +46,7 @@ describe('useStaff hooks', () => {
|
||||
name: 'John Doe',
|
||||
email: 'john@example.com',
|
||||
phone: '555-1234',
|
||||
role: 'TENANT_MANAGER',
|
||||
role: 'TENANT_STAFF',
|
||||
is_active: true,
|
||||
permissions: { can_invite_staff: true },
|
||||
can_invite_staff: true,
|
||||
@@ -79,7 +79,7 @@ describe('useStaff hooks', () => {
|
||||
name: 'John Doe',
|
||||
email: 'john@example.com',
|
||||
phone: '555-1234',
|
||||
role: 'TENANT_MANAGER',
|
||||
role: 'TENANT_STAFF',
|
||||
is_active: true,
|
||||
permissions: { can_invite_staff: true },
|
||||
can_invite_staff: true,
|
||||
|
||||
@@ -122,11 +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.
|
||||
* Note: Backend maps tenant_owner -> owner, tenant_staff -> staff, etc.
|
||||
*/
|
||||
const getRedirectPathForRole = (role: string): string => {
|
||||
// Tenant roles (as returned by backend after role mapping)
|
||||
const tenantRoles = ['owner', 'manager', 'staff', 'customer'];
|
||||
const tenantRoles = ['owner', 'staff', 'customer'];
|
||||
if (tenantRoles.includes(role)) {
|
||||
return '/dashboard/';
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ const transformCustomer = (c: any): Customer => ({
|
||||
paymentMethods: [],
|
||||
user_data: c.user_data,
|
||||
notes: c.notes || '',
|
||||
email_verified: c.email_verified ?? false,
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -208,3 +209,20 @@ export const useDeleteCustomer = () => {
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to verify a customer's email address
|
||||
*/
|
||||
export const useVerifyCustomerEmail = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
const { data } = await apiClient.post(`/customers/${id}/verify_email/`);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['customers'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -8,7 +8,7 @@ import apiClient from '../api/client';
|
||||
export interface StaffInvitation {
|
||||
id: number;
|
||||
email: string;
|
||||
role: 'TENANT_MANAGER' | 'TENANT_STAFF';
|
||||
role: 'TENANT_STAFF';
|
||||
role_display: string;
|
||||
status: 'PENDING' | 'ACCEPTED' | 'DECLINED' | 'EXPIRED' | 'CANCELLED';
|
||||
invited_by: number | null;
|
||||
@@ -50,7 +50,7 @@ export interface StaffPermissions {
|
||||
|
||||
export interface CreateInvitationData {
|
||||
email: string;
|
||||
role: 'TENANT_MANAGER' | 'TENANT_STAFF';
|
||||
role: 'TENANT_STAFF';
|
||||
create_bookable_resource?: boolean;
|
||||
resource_name?: string;
|
||||
permissions?: StaffPermissions;
|
||||
|
||||
@@ -13,6 +13,8 @@ export interface StaffPermissions {
|
||||
export interface StaffMember {
|
||||
id: string;
|
||||
name: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
email: string;
|
||||
phone?: string;
|
||||
role: string;
|
||||
@@ -22,6 +24,7 @@ export interface StaffMember {
|
||||
staff_role_id: number | null;
|
||||
staff_role_name: string | null;
|
||||
effective_permissions: Record<string, boolean>;
|
||||
email_verified: boolean;
|
||||
}
|
||||
|
||||
interface StaffFilters {
|
||||
@@ -30,7 +33,7 @@ interface StaffFilters {
|
||||
|
||||
/**
|
||||
* Hook to fetch staff members with optional filters
|
||||
* Staff members are Users with roles: TENANT_OWNER, TENANT_MANAGER, TENANT_STAFF
|
||||
* Staff members are Users with roles: TENANT_OWNER, TENANT_STAFF
|
||||
*/
|
||||
export const useStaff = (filters?: StaffFilters) => {
|
||||
return useQuery<StaffMember[]>({
|
||||
@@ -46,6 +49,8 @@ export const useStaff = (filters?: StaffFilters) => {
|
||||
return data.map((s: any) => ({
|
||||
id: String(s.id),
|
||||
name: s.name || `${s.first_name || ''} ${s.last_name || ''}`.trim() || s.email,
|
||||
first_name: s.first_name || '',
|
||||
last_name: s.last_name || '',
|
||||
email: s.email || '',
|
||||
phone: s.phone || '',
|
||||
role: s.role || 'staff',
|
||||
@@ -55,14 +60,27 @@ export const useStaff = (filters?: StaffFilters) => {
|
||||
staff_role_id: s.staff_role_id ?? null,
|
||||
staff_role_name: s.staff_role_name ?? null,
|
||||
effective_permissions: s.effective_permissions || {},
|
||||
email_verified: s.email_verified ?? false,
|
||||
}));
|
||||
},
|
||||
retry: false,
|
||||
});
|
||||
};
|
||||
|
||||
export interface StaffProfileUpdate {
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
export interface StaffUpdate extends StaffProfileUpdate {
|
||||
is_active?: boolean;
|
||||
permissions?: StaffPermissions;
|
||||
staff_role_id?: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to update a staff member's settings
|
||||
* Hook to update a staff member's settings and profile
|
||||
*/
|
||||
export const useUpdateStaff = () => {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -73,7 +91,7 @@ export const useUpdateStaff = () => {
|
||||
updates,
|
||||
}: {
|
||||
id: string;
|
||||
updates: { is_active?: boolean; permissions?: StaffPermissions; staff_role_id?: number | null };
|
||||
updates: StaffUpdate;
|
||||
}) => {
|
||||
const { data } = await apiClient.patch(`/staff/${id}/`, updates);
|
||||
return data;
|
||||
@@ -102,3 +120,38 @@ export const useToggleStaffActive = () => {
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to verify a staff member's email address
|
||||
*/
|
||||
export const useVerifyStaffEmail = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
const { data } = await apiClient.post(`/staff/${id}/verify_email/`);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['staff'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['businessUsers'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to send a password reset email to a staff member
|
||||
*/
|
||||
export const useSendStaffPasswordReset = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
const { data } = await apiClient.post(`/staff/${id}/send_password_reset/`);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['staff'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -917,7 +917,19 @@
|
||||
"noCustomersFound": "Keine Kunden gefunden, die Ihrer Suche entsprechen.",
|
||||
"addNewCustomer": "Neuen Kunden Hinzufügen",
|
||||
"createCustomer": "Kunden Erstellen",
|
||||
"errorLoading": "Fehler beim Laden der Kunden"
|
||||
"errorLoading": "Fehler beim Laden der Kunden",
|
||||
"password": "Passwort",
|
||||
"newPassword": "Neues Passwort",
|
||||
"passwordPlaceholder": "Leer lassen, um das aktuelle Passwort zu behalten",
|
||||
"accountInfo": "Kontoinformationen",
|
||||
"contactDetails": "Kontaktdetails",
|
||||
"verifyEmail": "E-Mail bestätigen",
|
||||
"unverifyEmail": "E-Mail nicht bestätigen",
|
||||
"emailVerified": "Bestätigt",
|
||||
"verifyEmailTitle": "E-Mail-Adresse bestätigen",
|
||||
"unverifyEmailTitle": "E-Mail-Bestätigung aufheben",
|
||||
"verifyEmailConfirm": "Möchten Sie {{email}} wirklich als bestätigt markieren?",
|
||||
"unverifyEmailConfirm": "Möchten Sie die Bestätigung von {{email}} wirklich aufheben?"
|
||||
},
|
||||
"staff": {
|
||||
"title": "Personal & Management",
|
||||
@@ -930,7 +942,15 @@
|
||||
"yes": "Ja",
|
||||
"errorLoading": "Fehler beim Laden des Personals",
|
||||
"inviteModalTitle": "Personal Einladen",
|
||||
"inviteModalDescription": "Der Benutzereinladungsablauf würde hier sein."
|
||||
"inviteModalDescription": "Der Benutzereinladungsablauf würde hier sein.",
|
||||
"verifyEmail": "E-Mail bestätigen",
|
||||
"emailVerified": "Bestätigt",
|
||||
"emailStatus": "E-Mail-Status",
|
||||
"unverifyEmail": "E-Mail nicht bestätigen",
|
||||
"verifyEmailTitle": "E-Mail-Adresse bestätigen",
|
||||
"unverifyEmailTitle": "E-Mail-Bestätigung aufheben",
|
||||
"verifyEmailConfirm": "Möchten Sie {{email}} wirklich als bestätigt markieren?",
|
||||
"unverifyEmailConfirm": "Möchten Sie die Bestätigung von {{email}} wirklich aufheben?"
|
||||
},
|
||||
"resources": {
|
||||
"title": "Ressourcen",
|
||||
|
||||
@@ -936,9 +936,8 @@
|
||||
"emailPlaceholder": "colleague@example.com",
|
||||
"roleLabel": "Role",
|
||||
"roleStaff": "Staff Member",
|
||||
"roleManager": "Manager",
|
||||
"managerRoleHint": "Managers can manage staff, resources, and view reports",
|
||||
"staffRoleHint": "Staff members can manage their own schedule and appointments",
|
||||
"roleOwner": "Owner",
|
||||
"staffRoleHint": "Staff permissions are determined by their assigned role",
|
||||
"makeBookableHint": "Create a bookable resource so customers can schedule appointments with this person",
|
||||
"resourceName": "Display Name (optional)",
|
||||
"resourceNamePlaceholder": "Defaults to person's name",
|
||||
@@ -958,7 +957,7 @@
|
||||
"canSendMessagesHint": "Send messages to groups of staff and customers",
|
||||
"deactivate": "Deactivate",
|
||||
"canInviteStaff": "Can invite new staff members",
|
||||
"canInviteStaffHint": "Allow this manager to send invitations to new staff members",
|
||||
"canInviteStaffHint": "Allow this staff member to send invitations to new staff members",
|
||||
"canManageResources": "Can manage resources",
|
||||
"canManageResourcesHint": "Create, edit, and delete bookable resources",
|
||||
"canManageServices": "Can manage services",
|
||||
@@ -966,7 +965,37 @@
|
||||
"canViewReports": "Can view reports",
|
||||
"canViewReportsHint": "Access business analytics and financial reports",
|
||||
"canAccessSettings": "Can access business settings",
|
||||
"canAccessSettingsHint": "Modify business profile, branding, and configuration",
|
||||
"canAccessSettingsHint": "Access to business settings pages (select specific pages below)",
|
||||
"canAccessSettingsGeneral": "General Settings",
|
||||
"canAccessSettingsGeneralHint": "Business name, timezone, and basic configuration",
|
||||
"canAccessSettingsBusinessHours": "Business Hours",
|
||||
"canAccessSettingsBusinessHoursHint": "Set regular operating hours",
|
||||
"canAccessSettingsBranding": "Branding",
|
||||
"canAccessSettingsBrandingHint": "Logo, colors, and visual identity",
|
||||
"canAccessSettingsBooking": "Booking Settings",
|
||||
"canAccessSettingsBookingHint": "Booking policies and rules",
|
||||
"canAccessSettingsCommunication": "Communication",
|
||||
"canAccessSettingsCommunicationHint": "Notification preferences and reminders",
|
||||
"canAccessSettingsEmbedWidget": "Embed Widget",
|
||||
"canAccessSettingsEmbedWidgetHint": "Configure booking widget for websites",
|
||||
"canAccessSettingsEmailTemplates": "Email Templates",
|
||||
"canAccessSettingsEmailTemplatesHint": "Customize automated emails",
|
||||
"canAccessSettingsStaffRoles": "Staff Roles",
|
||||
"canAccessSettingsStaffRolesHint": "Create and manage permission roles",
|
||||
"canAccessSettingsResourceTypes": "Resource Types",
|
||||
"canAccessSettingsResourceTypesHint": "Configure resource categories",
|
||||
"canAccessSettingsApi": "API & Integrations",
|
||||
"canAccessSettingsApiHint": "Manage API tokens and webhooks",
|
||||
"canAccessSettingsCustomDomains": "Custom Domains",
|
||||
"canAccessSettingsCustomDomainsHint": "Configure custom domain settings",
|
||||
"canAccessSettingsAuthentication": "Authentication",
|
||||
"canAccessSettingsAuthenticationHint": "OAuth and social login configuration",
|
||||
"canAccessSettingsEmail": "Email Setup",
|
||||
"canAccessSettingsEmailHint": "Configure email addresses for tickets",
|
||||
"canAccessSettingsSmsCalling": "SMS & Calling",
|
||||
"canAccessSettingsSmsCallingHint": "Manage credits and phone numbers",
|
||||
"selectAll": "Select All",
|
||||
"selectNone": "Select None",
|
||||
"canRefundPayments": "Can refund payments",
|
||||
"canRefundPaymentsHint": "Process refunds for customer payments",
|
||||
"canViewAllSchedules": "Can view all schedules",
|
||||
@@ -974,16 +1003,41 @@
|
||||
"canManageOwnAppointments": "Can manage own appointments",
|
||||
"canManageOwnAppointmentsHint": "Create, reschedule, and cancel their own appointments",
|
||||
"canSelfApproveTimeOff": "Can self-approve time off",
|
||||
"canSelfApproveTimeOffHint": "Add time off without requiring manager/owner approval",
|
||||
"canSelfApproveTimeOffHint": "Add time off without requiring owner approval",
|
||||
"canAccessTickets": "Can access support tickets",
|
||||
"canAccessTicketsHint": "View and manage customer support tickets",
|
||||
"managerPermissions": "Manager Permissions",
|
||||
"staffPermissions": "Staff Permissions"
|
||||
"staffPermissions": "Staff Permissions",
|
||||
"verifyEmail": "Verify Email",
|
||||
"unverifyEmail": "Unverify Email",
|
||||
"emailVerified": "Verified",
|
||||
"emailStatus": "Email Status",
|
||||
"verifyEmailTitle": "Verify Email Address",
|
||||
"unverifyEmailTitle": "Unverify Email Address",
|
||||
"verifyEmailConfirm": "Are you sure you want to mark {{email}} as verified?",
|
||||
"unverifyEmailConfirm": "Are you sure you want to mark {{email}} as unverified?",
|
||||
"profileInformation": "Profile Information",
|
||||
"firstName": "First Name",
|
||||
"firstNamePlaceholder": "Enter first name",
|
||||
"lastName": "Last Name",
|
||||
"lastNamePlaceholder": "Enter last name",
|
||||
"email": "Email",
|
||||
"phone": "Phone",
|
||||
"phonePlaceholder": "Enter phone number",
|
||||
"verified": "Verified",
|
||||
"verify": "Verify",
|
||||
"roleAndPermissions": "Role & Permissions",
|
||||
"accountSecurity": "Account Security",
|
||||
"resetPassword": "Reset Password",
|
||||
"resetPasswordHint": "Send a password reset email to this staff member",
|
||||
"sendResetEmail": "Send Reset Email",
|
||||
"confirmPasswordReset": "Send a password reset email to {{email}}? They will receive a temporary password.",
|
||||
"passwordResetSent": "Password reset email sent successfully",
|
||||
"passwordResetFailed": "Failed to send password reset email"
|
||||
},
|
||||
"staffDashboard": {
|
||||
"welcomeTitle": "Welcome, {{name}}!",
|
||||
"weekOverview": "Here's your week at a glance",
|
||||
"noResourceLinked": "Your account is not linked to a resource yet. Please contact your manager to set up your schedule.",
|
||||
"noResourceLinked": "Your account is not linked to a resource yet. Please contact the business owner to set up your schedule.",
|
||||
"currentAppointment": "Current Appointment",
|
||||
"nextAppointment": "Next Appointment",
|
||||
"viewSchedule": "View Schedule",
|
||||
@@ -1478,7 +1532,14 @@
|
||||
"newPassword": "New Password",
|
||||
"passwordPlaceholder": "Leave blank to keep current password",
|
||||
"accountInfo": "Account Information",
|
||||
"contactDetails": "Contact Details"
|
||||
"contactDetails": "Contact Details",
|
||||
"verifyEmail": "Verify Email",
|
||||
"unverifyEmail": "Unverify Email",
|
||||
"emailVerified": "Verified",
|
||||
"verifyEmailTitle": "Verify Email Address",
|
||||
"unverifyEmailTitle": "Unverify Email Address",
|
||||
"verifyEmailConfirm": "Are you sure you want to mark {{email}} as verified?",
|
||||
"unverifyEmailConfirm": "Are you sure you want to mark {{email}} as unverified?"
|
||||
},
|
||||
"resources": {
|
||||
"title": "Resources",
|
||||
@@ -1742,6 +1803,7 @@
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"noPermission": "You do not have permission to access these settings.",
|
||||
"businessSettings": "Business Settings",
|
||||
"businessSettingsDescription": "Manage your branding, domain, and policies.",
|
||||
"domainIdentity": "Domain & Identity",
|
||||
@@ -1921,6 +1983,12 @@
|
||||
"roleDescriptionPlaceholder": "Brief description of this role's responsibilities",
|
||||
"permissions": "Permissions",
|
||||
"menuAccess": "Menu Access",
|
||||
"menuPermissions": "Menu Access",
|
||||
"menuPermissionsDescription": "Control which pages staff can see in the sidebar.",
|
||||
"settingsPermissions": "Business Settings Access",
|
||||
"settingsPermissionsDescription": "Control which settings pages staff can access.",
|
||||
"dangerousPermissions": "Dangerous Operations",
|
||||
"dangerousPermissionsDescription": "Allow staff to perform destructive or sensitive actions.",
|
||||
"dangerousOperations": "Dangerous Operations",
|
||||
"staffAssigned": "{{count}} staff assigned",
|
||||
"noStaffAssigned": "No staff assigned",
|
||||
@@ -3406,7 +3474,7 @@
|
||||
"title": "My Availability",
|
||||
"subtitle": "Manage your time off and unavailability",
|
||||
"noResource": "No Resource Linked",
|
||||
"noResourceDesc": "Your account is not linked to a resource. Please contact your manager to set up your availability.",
|
||||
"noResourceDesc": "Your account is not linked to a resource. Please contact the business owner to set up your availability.",
|
||||
"addBlock": "Block Time",
|
||||
"businessBlocks": "Business Closures",
|
||||
"businessBlocksInfo": "These blocks are set by your business and apply to everyone.",
|
||||
@@ -3717,14 +3785,12 @@
|
||||
"staffRoles": "Staff Roles",
|
||||
"ownerRole": "Owner",
|
||||
"ownerRoleDesc": "Full access to everything including billing and settings. Cannot be removed.",
|
||||
"managerRole": "Manager",
|
||||
"managerRoleDesc": "Can manage staff, customers, services, and appointments. No billing access.",
|
||||
"staffRole": "Staff",
|
||||
"staffRoleDesc": "Basic access. Can view scheduler and manage own appointments if bookable.",
|
||||
"staffRoleDesc": "Access is controlled by their assigned staff role. Create custom roles in Settings > Staff Roles.",
|
||||
"invitingStaff": "Inviting Staff",
|
||||
"inviteStep1": "Click the Invite Staff button",
|
||||
"inviteStep2": "Enter their email address",
|
||||
"inviteStep3": "Select a role (Manager or Staff)",
|
||||
"inviteStep3": "Select a staff role to assign",
|
||||
"inviteStep4": "Click Send Invitation",
|
||||
"inviteStep5": "They'll receive an email with a link to join",
|
||||
"makeBookable": "Make Bookable",
|
||||
|
||||
@@ -967,6 +967,13 @@
|
||||
"lastVisit": "Última Visita",
|
||||
"nextAppointment": "Próxima Cita",
|
||||
"contactInfo": "Información de Contacto",
|
||||
"verifyEmail": "Verificar correo",
|
||||
"unverifyEmail": "Anular verificación",
|
||||
"emailVerified": "Verificado",
|
||||
"verifyEmailTitle": "Verificar correo electrónico",
|
||||
"unverifyEmailTitle": "Anular verificación de correo",
|
||||
"verifyEmailConfirm": "¿Está seguro de que desea marcar {{email}} como verificado?",
|
||||
"unverifyEmailConfirm": "¿Está seguro de que desea anular la verificación de {{email}}?",
|
||||
"status": "Estado",
|
||||
"active": "Activo",
|
||||
"inactive": "Inactivo",
|
||||
@@ -987,6 +994,14 @@
|
||||
"role": "Rol",
|
||||
"bookableResource": "Recurso Reservable",
|
||||
"makeBookable": "Hacer Reservable",
|
||||
"verifyEmail": "Verificar correo",
|
||||
"unverifyEmail": "Anular verificación",
|
||||
"emailVerified": "Verificado",
|
||||
"emailStatus": "Estado del correo",
|
||||
"verifyEmailTitle": "Verificar correo electrónico",
|
||||
"unverifyEmailTitle": "Anular verificación de correo",
|
||||
"verifyEmailConfirm": "¿Está seguro de que desea marcar {{email}} como verificado?",
|
||||
"unverifyEmailConfirm": "¿Está seguro de que desea anular la verificación de {{email}}?",
|
||||
"yes": "Sí",
|
||||
"errorLoading": "Error al cargar personal",
|
||||
"inviteModalTitle": "Invitar Personal",
|
||||
|
||||
@@ -907,6 +907,13 @@
|
||||
"lastVisit": "Dernière Visite",
|
||||
"nextAppointment": "Prochain Rendez-vous",
|
||||
"contactInfo": "Informations de Contact",
|
||||
"verifyEmail": "Vérifier l'e-mail",
|
||||
"unverifyEmail": "Annuler la vérification",
|
||||
"emailVerified": "Vérifié",
|
||||
"verifyEmailTitle": "Vérifier l'adresse e-mail",
|
||||
"unverifyEmailTitle": "Annuler la vérification de l'e-mail",
|
||||
"verifyEmailConfirm": "Êtes-vous sûr de vouloir marquer {{email}} comme vérifié ?",
|
||||
"unverifyEmailConfirm": "Êtes-vous sûr de vouloir annuler la vérification de {{email}} ?",
|
||||
"status": "Statut",
|
||||
"active": "Actif",
|
||||
"inactive": "Inactif",
|
||||
@@ -930,7 +937,17 @@
|
||||
"yes": "Oui",
|
||||
"errorLoading": "Erreur lors du chargement du personnel",
|
||||
"inviteModalTitle": "Inviter du Personnel",
|
||||
"inviteModalDescription": "Le flux d'invitation utilisateur irait ici."
|
||||
"inviteModalDescription": "Le flux d'invitation utilisateur irait ici.",
|
||||
"managerPermissions": "Permissions du Gestionnaire",
|
||||
"staffPermissions": "Permissions du Personnel",
|
||||
"verifyEmail": "Vérifier l'e-mail",
|
||||
"unverifyEmail": "Annuler la vérification",
|
||||
"verifyEmailTitle": "Vérifier l'adresse e-mail",
|
||||
"unverifyEmailTitle": "Annuler la vérification de l'e-mail",
|
||||
"verifyEmailConfirm": "Êtes-vous sûr de vouloir marquer {{email}} comme vérifié ?",
|
||||
"unverifyEmailConfirm": "Êtes-vous sûr de vouloir annuler la vérification de {{email}} ?",
|
||||
"emailVerified": "Vérifié",
|
||||
"emailStatus": "Statut de l'e-mail"
|
||||
},
|
||||
"resources": {
|
||||
"title": "Ressources",
|
||||
|
||||
@@ -56,6 +56,16 @@ const SettingsLayout: React.FC = () => {
|
||||
|
||||
// Get context from parent route (BusinessLayout)
|
||||
const parentContext = useOutletContext<ParentContext>();
|
||||
const { user } = parentContext || {};
|
||||
const isOwner = user?.role === 'owner';
|
||||
|
||||
// Check if staff has access to a specific settings page
|
||||
const hasSettingsPermission = (permissionKey: string): boolean => {
|
||||
// Owners always have all permissions
|
||||
if (isOwner) return true;
|
||||
// Staff need the specific permission
|
||||
return user?.effective_permissions?.[permissionKey] === true;
|
||||
};
|
||||
|
||||
// Check if a feature is locked (returns true if locked)
|
||||
const isLocked = (feature: FeatureKey | undefined): boolean => {
|
||||
@@ -92,123 +102,167 @@ const SettingsLayout: React.FC = () => {
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 px-2 pb-4 space-y-3 overflow-y-auto">
|
||||
{/* Business Section */}
|
||||
<SettingsSidebarSection title={t('settings.sections.business', 'Business')}>
|
||||
<SettingsSidebarItem
|
||||
to="/dashboard/settings/general"
|
||||
icon={Building2}
|
||||
label={t('settings.general.title', 'General')}
|
||||
description={t('settings.general.description', 'Name, timezone, contact')}
|
||||
/>
|
||||
<SettingsSidebarItem
|
||||
to="/dashboard/settings/resource-types"
|
||||
icon={Layers}
|
||||
label={t('settings.resourceTypes.title', 'Resource Types')}
|
||||
description={t('settings.resourceTypes.description', 'Staff, rooms, equipment')}
|
||||
/>
|
||||
<SettingsSidebarItem
|
||||
to="/dashboard/settings/booking"
|
||||
icon={Calendar}
|
||||
label={t('settings.booking.title', 'Booking')}
|
||||
description={t('settings.booking.description', 'Booking URL, redirects')}
|
||||
/>
|
||||
<SettingsSidebarItem
|
||||
to="/dashboard/settings/business-hours"
|
||||
icon={Clock}
|
||||
label={t('settings.businessHours.title', 'Business Hours')}
|
||||
description={t('settings.businessHours.description', 'Operating hours')}
|
||||
/>
|
||||
</SettingsSidebarSection>
|
||||
{(hasSettingsPermission('can_access_settings_general') ||
|
||||
hasSettingsPermission('can_access_settings_resource_types') ||
|
||||
hasSettingsPermission('can_access_settings_booking') ||
|
||||
hasSettingsPermission('can_access_settings_business_hours')) && (
|
||||
<SettingsSidebarSection title={t('settings.sections.business', 'Business')}>
|
||||
{hasSettingsPermission('can_access_settings_general') && (
|
||||
<SettingsSidebarItem
|
||||
to="/dashboard/settings/general"
|
||||
icon={Building2}
|
||||
label={t('settings.general.title', 'General')}
|
||||
description={t('settings.general.description', 'Name, timezone, contact')}
|
||||
/>
|
||||
)}
|
||||
{hasSettingsPermission('can_access_settings_resource_types') && (
|
||||
<SettingsSidebarItem
|
||||
to="/dashboard/settings/resource-types"
|
||||
icon={Layers}
|
||||
label={t('settings.resourceTypes.title', 'Resource Types')}
|
||||
description={t('settings.resourceTypes.description', 'Staff, rooms, equipment')}
|
||||
/>
|
||||
)}
|
||||
{hasSettingsPermission('can_access_settings_booking') && (
|
||||
<SettingsSidebarItem
|
||||
to="/dashboard/settings/booking"
|
||||
icon={Calendar}
|
||||
label={t('settings.booking.title', 'Booking')}
|
||||
description={t('settings.booking.description', 'Booking URL, redirects')}
|
||||
/>
|
||||
)}
|
||||
{hasSettingsPermission('can_access_settings_business_hours') && (
|
||||
<SettingsSidebarItem
|
||||
to="/dashboard/settings/business-hours"
|
||||
icon={Clock}
|
||||
label={t('settings.businessHours.title', 'Business Hours')}
|
||||
description={t('settings.businessHours.description', 'Operating hours')}
|
||||
/>
|
||||
)}
|
||||
</SettingsSidebarSection>
|
||||
)}
|
||||
|
||||
{/* Branding Section */}
|
||||
<SettingsSidebarSection title={t('settings.sections.branding', 'Branding')}>
|
||||
<SettingsSidebarItem
|
||||
to="/dashboard/settings/branding"
|
||||
icon={Palette}
|
||||
label={t('settings.appearance.title', 'Appearance')}
|
||||
description={t('settings.appearance.description', 'Logo, colors, theme')}
|
||||
locked={isLocked('remove_branding')}
|
||||
/>
|
||||
<SettingsSidebarItem
|
||||
to="/dashboard/settings/email-templates"
|
||||
icon={Mail}
|
||||
label={t('settings.emailTemplates.title', 'Email Templates')}
|
||||
description={t('settings.emailTemplates.description', 'Customize automated emails')}
|
||||
/>
|
||||
<SettingsSidebarItem
|
||||
to="/dashboard/settings/custom-domains"
|
||||
icon={Globe}
|
||||
label={t('settings.customDomains.title', 'Custom Domains')}
|
||||
description={t('settings.customDomains.description', 'Use your own domain')}
|
||||
locked={isLocked('custom_domain')}
|
||||
/>
|
||||
<SettingsSidebarItem
|
||||
to="/dashboard/settings/embed-widget"
|
||||
icon={Code2}
|
||||
label={t('settings.embedWidget.title', 'Embed Widget')}
|
||||
description={t('settings.embedWidget.sidebarDescription', 'Add booking to your site')}
|
||||
/>
|
||||
</SettingsSidebarSection>
|
||||
{(hasSettingsPermission('can_access_settings_branding') ||
|
||||
hasSettingsPermission('can_access_settings_email_templates') ||
|
||||
hasSettingsPermission('can_access_settings_custom_domains') ||
|
||||
hasSettingsPermission('can_access_settings_embed_widget')) && (
|
||||
<SettingsSidebarSection title={t('settings.sections.branding', 'Branding')}>
|
||||
{hasSettingsPermission('can_access_settings_branding') && (
|
||||
<SettingsSidebarItem
|
||||
to="/dashboard/settings/branding"
|
||||
icon={Palette}
|
||||
label={t('settings.appearance.title', 'Appearance')}
|
||||
description={t('settings.appearance.description', 'Logo, colors, theme')}
|
||||
locked={isLocked('remove_branding')}
|
||||
/>
|
||||
)}
|
||||
{hasSettingsPermission('can_access_settings_email_templates') && (
|
||||
<SettingsSidebarItem
|
||||
to="/dashboard/settings/email-templates"
|
||||
icon={Mail}
|
||||
label={t('settings.emailTemplates.title', 'Email Templates')}
|
||||
description={t('settings.emailTemplates.description', 'Customize automated emails')}
|
||||
/>
|
||||
)}
|
||||
{hasSettingsPermission('can_access_settings_custom_domains') && (
|
||||
<SettingsSidebarItem
|
||||
to="/dashboard/settings/custom-domains"
|
||||
icon={Globe}
|
||||
label={t('settings.customDomains.title', 'Custom Domains')}
|
||||
description={t('settings.customDomains.description', 'Use your own domain')}
|
||||
locked={isLocked('custom_domain')}
|
||||
/>
|
||||
)}
|
||||
{hasSettingsPermission('can_access_settings_embed_widget') && (
|
||||
<SettingsSidebarItem
|
||||
to="/dashboard/settings/embed-widget"
|
||||
icon={Code2}
|
||||
label={t('settings.embedWidget.title', 'Embed Widget')}
|
||||
description={t('settings.embedWidget.sidebarDescription', 'Add booking to your site')}
|
||||
/>
|
||||
)}
|
||||
</SettingsSidebarSection>
|
||||
)}
|
||||
|
||||
{/* Integrations Section */}
|
||||
<SettingsSidebarSection title={t('settings.sections.integrations', 'Integrations')}>
|
||||
<SettingsSidebarItem
|
||||
to="/dashboard/settings/api"
|
||||
icon={Key}
|
||||
label={t('settings.api.title', 'API & Webhooks')}
|
||||
description={t('settings.api.description', 'API tokens, webhooks')}
|
||||
locked={isLocked('api_access')}
|
||||
/>
|
||||
</SettingsSidebarSection>
|
||||
{hasSettingsPermission('can_access_settings_api') && (
|
||||
<SettingsSidebarSection title={t('settings.sections.integrations', 'Integrations')}>
|
||||
<SettingsSidebarItem
|
||||
to="/dashboard/settings/api"
|
||||
icon={Key}
|
||||
label={t('settings.api.title', 'API & Webhooks')}
|
||||
description={t('settings.api.description', 'API tokens, webhooks')}
|
||||
locked={isLocked('api_access')}
|
||||
/>
|
||||
</SettingsSidebarSection>
|
||||
)}
|
||||
|
||||
{/* 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}
|
||||
label={t('settings.authentication.title', 'Authentication')}
|
||||
description={t('settings.authentication.description', 'OAuth, social login')}
|
||||
locked={isLocked('custom_oauth')}
|
||||
/>
|
||||
</SettingsSidebarSection>
|
||||
{(hasSettingsPermission('can_access_settings_staff_roles') ||
|
||||
hasSettingsPermission('can_access_settings_authentication')) && (
|
||||
<SettingsSidebarSection title={t('settings.sections.access', 'Access')}>
|
||||
{hasSettingsPermission('can_access_settings_staff_roles') && (
|
||||
<SettingsSidebarItem
|
||||
to="/dashboard/settings/staff-roles"
|
||||
icon={Users}
|
||||
label={t('settings.staffRoles.title', 'Staff Roles')}
|
||||
description={t('settings.staffRoles.description', 'Role permissions')}
|
||||
/>
|
||||
)}
|
||||
{hasSettingsPermission('can_access_settings_authentication') && (
|
||||
<SettingsSidebarItem
|
||||
to="/dashboard/settings/authentication"
|
||||
icon={Lock}
|
||||
label={t('settings.authentication.title', 'Authentication')}
|
||||
description={t('settings.authentication.description', 'OAuth, social login')}
|
||||
locked={isLocked('custom_oauth')}
|
||||
/>
|
||||
)}
|
||||
</SettingsSidebarSection>
|
||||
)}
|
||||
|
||||
{/* Communication Section */}
|
||||
<SettingsSidebarSection title={t('settings.sections.communication', 'Communication')}>
|
||||
<SettingsSidebarItem
|
||||
to="/dashboard/settings/email"
|
||||
icon={Mail}
|
||||
label={t('settings.email.title', 'Email Setup')}
|
||||
description={t('settings.email.description', 'Email addresses for tickets')}
|
||||
/>
|
||||
<SettingsSidebarItem
|
||||
to="/dashboard/settings/sms-calling"
|
||||
icon={Phone}
|
||||
label={t('settings.smsCalling.title', 'SMS & Calling')}
|
||||
description={t('settings.smsCalling.description', 'Credits, phone numbers')}
|
||||
locked={isLocked('sms_reminders')}
|
||||
/>
|
||||
</SettingsSidebarSection>
|
||||
{(hasSettingsPermission('can_access_settings_email') ||
|
||||
hasSettingsPermission('can_access_settings_sms_calling')) && (
|
||||
<SettingsSidebarSection title={t('settings.sections.communication', 'Communication')}>
|
||||
{hasSettingsPermission('can_access_settings_email') && (
|
||||
<SettingsSidebarItem
|
||||
to="/dashboard/settings/email"
|
||||
icon={Mail}
|
||||
label={t('settings.email.title', 'Email Setup')}
|
||||
description={t('settings.email.description', 'Email addresses for tickets')}
|
||||
/>
|
||||
)}
|
||||
{hasSettingsPermission('can_access_settings_sms_calling') && (
|
||||
<SettingsSidebarItem
|
||||
to="/dashboard/settings/sms-calling"
|
||||
icon={Phone}
|
||||
label={t('settings.smsCalling.title', 'SMS & Calling')}
|
||||
description={t('settings.smsCalling.description', 'Credits, phone numbers')}
|
||||
locked={isLocked('sms_reminders')}
|
||||
/>
|
||||
)}
|
||||
</SettingsSidebarSection>
|
||||
)}
|
||||
|
||||
{/* Billing Section */}
|
||||
<SettingsSidebarSection title={t('settings.sections.billing', 'Billing')}>
|
||||
<SettingsSidebarItem
|
||||
to="/dashboard/settings/billing"
|
||||
icon={CreditCard}
|
||||
label={t('settings.billing.title', 'Plan & Billing')}
|
||||
description={t('settings.billing.description', 'Subscription, invoices')}
|
||||
/>
|
||||
<SettingsSidebarItem
|
||||
to="/dashboard/settings/quota"
|
||||
icon={AlertTriangle}
|
||||
label={t('settings.quota.title', 'Quota Management')}
|
||||
description={t('settings.quota.description', 'Usage limits, archiving')}
|
||||
/>
|
||||
</SettingsSidebarSection>
|
||||
{/* Billing Section - Owner only */}
|
||||
{isOwner && (
|
||||
<SettingsSidebarSection title={t('settings.sections.billing', 'Billing')}>
|
||||
<SettingsSidebarItem
|
||||
to="/dashboard/settings/billing"
|
||||
icon={CreditCard}
|
||||
label={t('settings.billing.title', 'Plan & Billing')}
|
||||
description={t('settings.billing.description', 'Subscription, invoices')}
|
||||
/>
|
||||
<SettingsSidebarItem
|
||||
to="/dashboard/settings/quota"
|
||||
icon={AlertTriangle}
|
||||
label={t('settings.quota.title', 'Quota Management')}
|
||||
description={t('settings.quota.description', 'Usage limits, archiving')}
|
||||
/>
|
||||
</SettingsSidebarSection>
|
||||
)}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import React, { useState, useMemo, useEffect, useRef, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Customer, User } from '../types';
|
||||
import { useCustomersInfinite, useCreateCustomer, useUpdateCustomer } from '../hooks/useCustomers';
|
||||
import { useCustomersInfinite, useCreateCustomer, useUpdateCustomer, useVerifyCustomerEmail } from '../hooks/useCustomers';
|
||||
import { useAppointments } from '../hooks/useAppointments';
|
||||
import { useServices } from '../hooks/useServices';
|
||||
import {
|
||||
@@ -26,7 +26,9 @@ import {
|
||||
FileText,
|
||||
StickyNote,
|
||||
History,
|
||||
Save
|
||||
Save,
|
||||
BadgeCheck,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import Portal from '../components/Portal';
|
||||
|
||||
@@ -68,6 +70,9 @@ const Customers: React.FC<CustomersProps> = ({ onMasquerade, effectiveUser }) =>
|
||||
isActive: true
|
||||
});
|
||||
|
||||
// Verify email confirmation modal state
|
||||
const [verifyEmailTarget, setVerifyEmailTarget] = useState<Customer | null>(null);
|
||||
|
||||
// Infinite scroll for customers
|
||||
const {
|
||||
data: customersData,
|
||||
@@ -81,6 +86,7 @@ const Customers: React.FC<CustomersProps> = ({ onMasquerade, effectiveUser }) =>
|
||||
const { data: services = [] } = useServices();
|
||||
const createCustomerMutation = useCreateCustomer();
|
||||
const updateCustomerMutation = useUpdateCustomer();
|
||||
const verifyEmailMutation = useVerifyCustomerEmail();
|
||||
|
||||
// Transform paginated data to flat array
|
||||
const customers: Customer[] = useMemo(() => {
|
||||
@@ -222,6 +228,20 @@ const Customers: React.FC<CustomersProps> = ({ onMasquerade, effectiveUser }) =>
|
||||
});
|
||||
};
|
||||
|
||||
const handleVerifyEmailClick = (customer: Customer) => {
|
||||
setVerifyEmailTarget(customer);
|
||||
};
|
||||
|
||||
const handleVerifyEmailConfirm = async () => {
|
||||
if (!verifyEmailTarget) return;
|
||||
try {
|
||||
await verifyEmailMutation.mutateAsync(verifyEmailTarget.id);
|
||||
setVerifyEmailTarget(null);
|
||||
} catch (err: any) {
|
||||
console.error('Failed to toggle email verification:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const getServiceName = (serviceId: string) => {
|
||||
const service = services.find(s => String(s.id) === serviceId);
|
||||
return service?.name || t('customers.unknownService');
|
||||
@@ -381,6 +401,22 @@ const Customers: React.FC<CustomersProps> = ({ onMasquerade, effectiveUser }) =>
|
||||
<td className="px-6 py-4 text-right text-gray-600 dark:text-gray-400">{customer.lastVisit ? customer.lastVisit.toLocaleDateString() : <span className="text-gray-400 italic">{t('customers.never')}</span>}</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleVerifyEmailClick(customer); }}
|
||||
disabled={verifyEmailMutation.isPending}
|
||||
className={`p-1.5 border rounded-lg transition-colors ${
|
||||
customer.email_verified
|
||||
? 'text-green-600 hover:text-green-700 dark:text-green-400 dark:hover:text-green-300 border-green-200 dark:border-green-800 hover:bg-green-50 dark:hover:bg-green-900/30'
|
||||
: 'text-amber-600 hover:text-amber-700 dark:text-amber-400 dark:hover:text-amber-300 border-amber-200 dark:border-amber-800 hover:bg-amber-50 dark:hover:bg-amber-900/30'
|
||||
}`}
|
||||
title={customer.email_verified ? t('customers.emailVerified') : t('customers.verifyEmail')}
|
||||
>
|
||||
{verifyEmailMutation.isPending ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
) : (
|
||||
<BadgeCheck size={16} />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleEditClick(customer); }}
|
||||
className="text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200 font-medium text-xs inline-flex items-center gap-1 px-3 py-1 border border-gray-200 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
@@ -807,6 +843,48 @@ const Customers: React.FC<CustomersProps> = ({ onMasquerade, effectiveUser }) =>
|
||||
</div>
|
||||
</Portal>
|
||||
)}
|
||||
|
||||
{/* Verify Email Confirmation Modal */}
|
||||
{verifyEmailTarget && (
|
||||
<Portal>
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-sm overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{verifyEmailTarget.email_verified ? t('customers.unverifyEmailTitle') : t('customers.verifyEmailTitle')}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{verifyEmailTarget.email_verified
|
||||
? t('customers.unverifyEmailConfirm', { email: verifyEmailTarget.email })
|
||||
: t('customers.verifyEmailConfirm', { email: verifyEmailTarget.email })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50 flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => setVerifyEmailTarget(null)}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleVerifyEmailConfirm}
|
||||
disabled={verifyEmailMutation.isPending}
|
||||
className={`px-4 py-2 text-sm font-medium text-white rounded-lg disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 ${
|
||||
verifyEmailTarget.email_verified
|
||||
? 'bg-amber-600 hover:bg-amber-700'
|
||||
: 'bg-green-600 hover:bg-green-700'
|
||||
}`}
|
||||
>
|
||||
{verifyEmailMutation.isPending && <Loader2 size={16} className="animate-spin" />}
|
||||
{verifyEmailTarget.email_verified ? t('customers.unverifyEmail') : t('customers.verifyEmail')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -251,7 +251,6 @@ const Messages: React.FC = () => {
|
||||
// Computed
|
||||
const roleOptions = [
|
||||
{ value: 'owner', label: 'Owners', icon: Users, description: 'Business owners' },
|
||||
{ value: 'manager', label: 'Managers', icon: Users, description: 'Team leads' },
|
||||
{ value: 'staff', label: 'Staff', icon: Users, description: 'Employees' },
|
||||
{ value: 'customer', label: 'Customers', icon: Users, description: 'Clients' },
|
||||
];
|
||||
|
||||
@@ -50,7 +50,7 @@ const Payments: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { user: effectiveUser, business } = useOutletContext<{ user: User, business: Business }>();
|
||||
|
||||
const isBusiness = effectiveUser.role === 'owner' || effectiveUser.role === 'manager';
|
||||
const isBusiness = effectiveUser.role === 'owner' || effectiveUser.role === 'staff';
|
||||
const isCustomer = effectiveUser.role === 'customer';
|
||||
|
||||
// Tab state
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { User } from '../types';
|
||||
import { useCreateResource, useResources } from '../hooks/useBusiness';
|
||||
import { useStaff, useToggleStaffActive, useUpdateStaff, StaffMember } from '../hooks/useStaff';
|
||||
import { useStaff, useToggleStaffActive, useUpdateStaff, useVerifyStaffEmail, useSendStaffPasswordReset, StaffMember } from '../hooks/useStaff';
|
||||
import {
|
||||
useInvitations,
|
||||
useCreateInvitation,
|
||||
@@ -30,9 +30,13 @@ import {
|
||||
ChevronRight,
|
||||
UserX,
|
||||
Power,
|
||||
BadgeCheck,
|
||||
Key,
|
||||
Phone,
|
||||
Eye,
|
||||
ArrowUpDown,
|
||||
} from 'lucide-react';
|
||||
import Portal from '../components/Portal';
|
||||
import StaffPermissions from '../components/StaffPermissions';
|
||||
|
||||
interface StaffProps {
|
||||
onMasquerade: (user: User) => void;
|
||||
@@ -51,10 +55,13 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
||||
const resendInvitationMutation = useResendInvitation();
|
||||
const toggleActiveMutation = useToggleStaffActive();
|
||||
const updateStaffMutation = useUpdateStaff();
|
||||
const verifyEmailMutation = useVerifyStaffEmail();
|
||||
const passwordResetMutation = useSendStaffPasswordReset();
|
||||
|
||||
const [isInviteModalOpen, setIsInviteModalOpen] = useState(false);
|
||||
const [inviteEmail, setInviteEmail] = useState('');
|
||||
const [inviteRole, setInviteRole] = useState<'TENANT_MANAGER' | 'TENANT_STAFF'>('TENANT_STAFF');
|
||||
// All invitations are for TENANT_STAFF - manager role removed
|
||||
const inviteRole = 'TENANT_STAFF';
|
||||
const [inviteStaffRoleId, setInviteStaffRoleId] = useState<number | null>(null);
|
||||
const [createBookableResource, setCreateBookableResource] = useState(false);
|
||||
const [resourceName, setResourceName] = useState('');
|
||||
@@ -66,16 +73,55 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
||||
// Edit modal state
|
||||
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 [editFirstName, setEditFirstName] = useState('');
|
||||
const [editLastName, setEditLastName] = useState('');
|
||||
const [editPhone, setEditPhone] = useState('');
|
||||
const [editError, setEditError] = useState('');
|
||||
const [editSuccess, setEditSuccess] = useState('');
|
||||
|
||||
// Check if user can invite managers (only owners can)
|
||||
const canInviteManagers = effectiveUser.role === 'owner';
|
||||
// Verify email confirmation modal state
|
||||
const [verifyEmailTarget, setVerifyEmailTarget] = useState<StaffMember | null>(null);
|
||||
|
||||
// Sorting state
|
||||
const [sortConfig, setSortConfig] = useState<{ key: 'name' | 'role'; direction: 'asc' | 'desc' }>({
|
||||
key: 'name',
|
||||
direction: 'asc'
|
||||
});
|
||||
|
||||
const handleSort = (key: 'name' | 'role') => {
|
||||
setSortConfig(current => ({
|
||||
key,
|
||||
direction: current.key === key && current.direction === 'asc' ? 'desc' : 'asc',
|
||||
}));
|
||||
};
|
||||
|
||||
// Separate active and inactive staff, then sort
|
||||
const activeStaff = useMemo(() => {
|
||||
const active = staffMembers.filter((s) => s.is_active);
|
||||
return [...active].sort((a, b) => {
|
||||
let aValue: string;
|
||||
let bValue: string;
|
||||
|
||||
if (sortConfig.key === 'name') {
|
||||
aValue = (a.name || a.email || '').toLowerCase();
|
||||
bValue = (b.name || b.email || '').toLowerCase();
|
||||
} else {
|
||||
// Sort by role: owners first, then by staff_role_name
|
||||
const aIsOwner = a.role === 'owner';
|
||||
const bIsOwner = b.role === 'owner';
|
||||
if (aIsOwner && !bIsOwner) return sortConfig.direction === 'asc' ? -1 : 1;
|
||||
if (!aIsOwner && bIsOwner) return sortConfig.direction === 'asc' ? 1 : -1;
|
||||
aValue = (a.staff_role_name || '').toLowerCase();
|
||||
bValue = (b.staff_role_name || '').toLowerCase();
|
||||
}
|
||||
|
||||
if (aValue < bValue) return sortConfig.direction === 'asc' ? -1 : 1;
|
||||
if (aValue > bValue) return sortConfig.direction === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
}, [staffMembers, sortConfig]);
|
||||
|
||||
// Separate active and inactive staff
|
||||
const activeStaff = staffMembers.filter((s) => s.is_active);
|
||||
const inactiveStaff = staffMembers.filter((s) => !s.is_active);
|
||||
|
||||
// Helper to check if a user is already linked to a resource
|
||||
@@ -151,7 +197,6 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
||||
|
||||
const openInviteModal = () => {
|
||||
setInviteEmail('');
|
||||
setInviteRole('TENANT_STAFF');
|
||||
setInviteStaffRoleId(null);
|
||||
setCreateBookableResource(false);
|
||||
setResourceName('');
|
||||
@@ -196,8 +241,10 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
||||
|
||||
const openEditModal = (staff: StaffMember) => {
|
||||
setEditingStaff(staff);
|
||||
setEditPermissions(staff.permissions || {});
|
||||
setEditStaffRoleId(staff.staff_role_id);
|
||||
setEditFirstName(staff.first_name);
|
||||
setEditLastName(staff.last_name);
|
||||
setEditPhone(staff.phone || '');
|
||||
setEditError('');
|
||||
setEditSuccess('');
|
||||
setIsEditModalOpen(true);
|
||||
@@ -206,8 +253,10 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
||||
const closeEditModal = () => {
|
||||
setIsEditModalOpen(false);
|
||||
setEditingStaff(null);
|
||||
setEditPermissions({});
|
||||
setEditStaffRoleId(null);
|
||||
setEditFirstName('');
|
||||
setEditLastName('');
|
||||
setEditPhone('');
|
||||
setEditError('');
|
||||
setEditSuccess('');
|
||||
};
|
||||
@@ -217,10 +266,17 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
||||
|
||||
setEditError('');
|
||||
try {
|
||||
const updates: { permissions: Record<string, boolean>; staff_role_id?: number | null } = {
|
||||
permissions: editPermissions,
|
||||
const updates: {
|
||||
staff_role_id?: number | null;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
phone?: string;
|
||||
} = {
|
||||
first_name: editFirstName,
|
||||
last_name: editLastName,
|
||||
phone: editPhone,
|
||||
};
|
||||
// Only include staff_role_id for staff users (not owners/managers)
|
||||
// Only include staff_role_id for staff users (not owners)
|
||||
if (editingStaff.role === 'staff') {
|
||||
updates.staff_role_id = editStaffRoleId;
|
||||
}
|
||||
@@ -237,6 +293,21 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendPasswordReset = async () => {
|
||||
if (!editingStaff) return;
|
||||
|
||||
if (!confirm(t('staff.confirmPasswordReset', { email: editingStaff.email }))) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await passwordResetMutation.mutateAsync(editingStaff.id);
|
||||
setEditSuccess(t('staff.passwordResetSent'));
|
||||
} catch (err: any) {
|
||||
setEditError(err.response?.data?.error || t('staff.passwordResetFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeactivateFromModal = async () => {
|
||||
if (!editingStaff) return;
|
||||
|
||||
@@ -251,6 +322,20 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerifyEmailClick = (user: StaffMember) => {
|
||||
setVerifyEmailTarget(user);
|
||||
};
|
||||
|
||||
const handleVerifyEmailConfirm = async () => {
|
||||
if (!verifyEmailTarget) return;
|
||||
try {
|
||||
await verifyEmailMutation.mutateAsync(verifyEmailTarget.id);
|
||||
setVerifyEmailTarget(null);
|
||||
} catch (err: any) {
|
||||
console.error('Failed to toggle email verification:', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-7xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
@@ -325,9 +410,12 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
||||
<table className="w-full text-sm text-left">
|
||||
<thead className="text-xs text-gray-500 dark:text-gray-400 uppercase bg-gray-50 dark:bg-gray-900/50 border-b border-gray-200 dark:border-gray-700">
|
||||
<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 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700/50 transition-colors" onClick={() => handleSort('name')}>
|
||||
<div className="flex items-center gap-1">{t('staff.name')} <ArrowUpDown size={14} className="text-gray-400" /></div>
|
||||
</th>
|
||||
<th className="px-6 py-4 font-medium cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700/50 transition-colors" onClick={() => handleSort('role')}>
|
||||
<div className="flex items-center gap-1">{t('staff.role')} <ArrowUpDown size={14} className="text-gray-400" /></div>
|
||||
</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>
|
||||
@@ -356,34 +444,19 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium capitalize ${
|
||||
className={`inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
user.role === 'owner'
|
||||
? 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300'
|
||||
: user.role === 'manager'
|
||||
: user.staff_role_name === 'Manager'
|
||||
? 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300'
|
||||
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{user.role === 'owner' && <Shield size={12} />}
|
||||
{user.role === 'manager' && <Briefcase size={12} />}
|
||||
{user.role}
|
||||
{user.staff_role_name === 'Manager' && <Briefcase size={12} />}
|
||||
{user.role === 'owner' ? t('staff.roleOwner') : (user.staff_role_name || t('staff.noRoleAssigned'))}
|
||||
</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">
|
||||
@@ -401,21 +474,38 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={() => handleVerifyEmailClick(user)}
|
||||
disabled={verifyEmailMutation.isPending}
|
||||
className={`p-1.5 border rounded-lg transition-colors ${
|
||||
user.email_verified
|
||||
? 'text-green-600 hover:text-green-700 dark:text-green-400 dark:hover:text-green-300 border-green-200 dark:border-green-800 hover:bg-green-50 dark:hover:bg-green-900/30'
|
||||
: 'text-amber-600 hover:text-amber-700 dark:text-amber-400 dark:hover:text-amber-300 border-amber-200 dark:border-amber-800 hover:bg-amber-50 dark:hover:bg-amber-900/30'
|
||||
}`}
|
||||
title={user.email_verified ? t('staff.emailVerified') : t('staff.verifyEmail')}
|
||||
>
|
||||
{verifyEmailMutation.isPending ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
) : (
|
||||
<BadgeCheck size={16} />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openEditModal(user)}
|
||||
className="text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200 font-medium text-xs inline-flex items-center gap-1 px-3 py-1 border border-gray-200 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
title={t('common.edit')}
|
||||
>
|
||||
<Pencil size={14} /> {t('common.edit')}
|
||||
</button>
|
||||
{canMasquerade && (
|
||||
<button
|
||||
onClick={() => onMasquerade(user)}
|
||||
className="text-indigo-600 hover:text-indigo-500 dark:text-indigo-400 dark:hover:text-indigo-300 font-medium text-xs inline-flex items-center gap-1 px-3 py-1 border border-indigo-200 dark:border-indigo-800 rounded-lg hover:bg-indigo-50 dark:hover:bg-indigo-900/30 transition-colors"
|
||||
title={t('common.masqueradeAsUser')}
|
||||
>
|
||||
{t('common.masquerade')}
|
||||
<Eye size={14} /> {t('common.masquerade')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => openEditModal(user)}
|
||||
className="p-2 text-gray-400 hover:text-brand-600 dark:hover:text-brand-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
title={t('common.edit')}
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -473,10 +563,10 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium capitalize bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-400">
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-400">
|
||||
{user.role === 'owner' && <Shield size={12} />}
|
||||
{user.role === 'manager' && <Briefcase size={12} />}
|
||||
{user.role}
|
||||
{user.staff_role_name === 'Manager' && <Briefcase size={12} />}
|
||||
{user.role === 'owner' ? t('staff.roleOwner') : (user.staff_role_name || t('staff.noRoleAssigned'))}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
@@ -545,33 +635,11 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Role Selector */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('staff.roleLabel')} *
|
||||
</label>
|
||||
<select
|
||||
value={inviteRole}
|
||||
onChange={(e) => setInviteRole(e.target.value as 'TENANT_MANAGER' | 'TENANT_STAFF')}
|
||||
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="TENANT_STAFF">{t('staff.roleStaff')}</option>
|
||||
{canInviteManagers && (
|
||||
<option value="TENANT_MANAGER">{t('staff.roleManager')}</option>
|
||||
)}
|
||||
</select>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{inviteRole === 'TENANT_MANAGER'
|
||||
? t('staff.managerRoleHint')
|
||||
: t('staff.staffRoleHint')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Staff Role Selector (only for staff invitations) */}
|
||||
{inviteRole === 'TENANT_STAFF' && staffRoles.length > 0 && (
|
||||
{/* Staff Role Selector */}
|
||||
{staffRoles.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('staff.staffRole')}
|
||||
{t('staff.roleLabel')} *
|
||||
</label>
|
||||
<select
|
||||
value={inviteStaffRoleId ?? ''}
|
||||
@@ -592,23 +660,12 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
||||
)}
|
||||
|
||||
{/* Permissions - Using shared component */}
|
||||
{inviteRole === 'TENANT_MANAGER' && (
|
||||
<StaffPermissions
|
||||
role="manager"
|
||||
permissions={invitePermissions}
|
||||
onChange={setInvitePermissions}
|
||||
variant="invite"
|
||||
/>
|
||||
)}
|
||||
|
||||
{inviteRole === 'TENANT_STAFF' && (
|
||||
<StaffPermissions
|
||||
role="staff"
|
||||
permissions={invitePermissions}
|
||||
onChange={setInvitePermissions}
|
||||
variant="invite"
|
||||
/>
|
||||
)}
|
||||
<StaffPermissions
|
||||
role="staff"
|
||||
permissions={invitePermissions}
|
||||
onChange={setInvitePermissions}
|
||||
variant="invite"
|
||||
/>
|
||||
|
||||
{/* Make Bookable Option */}
|
||||
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg border border-gray-200 dark:border-gray-600">
|
||||
@@ -692,8 +749,8 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
||||
{isEditModalOpen && editingStaff && (
|
||||
<Portal>
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-md overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-2xl overflow-hidden max-h-[90vh] flex flex-col">
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center flex-shrink-0">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{t('staff.editStaff')}
|
||||
</h3>
|
||||
@@ -705,80 +762,157 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Staff Info */}
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<div className="w-10 h-10 rounded-full bg-brand-100 dark:bg-brand-900/50 flex items-center justify-center text-brand-600 dark:text-brand-400 font-medium text-lg">
|
||||
{editingStaff.name.charAt(0).toUpperCase()}
|
||||
<div className="p-6 space-y-6 overflow-y-auto flex-1">
|
||||
{/* Profile Information Section */}
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-3 flex items-center gap-2">
|
||||
<UserIcon size={16} />
|
||||
{t('staff.profileInformation', 'Profile Information')}
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('staff.firstName', 'First Name')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editFirstName}
|
||||
onChange={(e) => setEditFirstName(e.target.value)}
|
||||
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"
|
||||
placeholder={t('staff.firstNamePlaceholder', 'Enter first name')}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('staff.lastName', 'Last Name')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editLastName}
|
||||
onChange={(e) => setEditLastName(e.target.value)}
|
||||
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"
|
||||
placeholder={t('staff.lastNamePlaceholder', 'Enter last name')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900 dark:text-white">{editingStaff.name}</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">{editingStaff.email}</div>
|
||||
<div className="mt-4">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('staff.email', 'Email')}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="email"
|
||||
value={editingStaff.email}
|
||||
disabled
|
||||
className="flex-1 px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 text-gray-500 dark:text-gray-400 cursor-not-allowed"
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleVerifyEmailClick(editingStaff)}
|
||||
disabled={verifyEmailMutation.isPending}
|
||||
className={`inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs font-medium transition-colors ${
|
||||
editingStaff.email_verified
|
||||
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400 hover:bg-green-200 dark:hover:bg-green-900/50'
|
||||
: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400 hover:bg-amber-200 dark:hover:bg-amber-900/50'
|
||||
}`}
|
||||
>
|
||||
{verifyEmailMutation.isPending ? (
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
) : (
|
||||
<BadgeCheck size={12} />
|
||||
)}
|
||||
{editingStaff.email_verified ? t('staff.verified', 'Verified') : t('staff.verify', 'Verify')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('staff.phone', 'Phone')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Phone size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="tel"
|
||||
value={editPhone}
|
||||
onChange={(e) => setEditPhone(e.target.value)}
|
||||
className="w-full pl-9 pr-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"
|
||||
placeholder={t('staff.phonePlaceholder', 'Enter phone number')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`ml-auto inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium capitalize ${
|
||||
editingStaff.role === 'owner'
|
||||
? 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300'
|
||||
: editingStaff.role === 'manager'
|
||||
? 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300'
|
||||
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{editingStaff.role === 'owner' && <Shield size={12} />}
|
||||
{editingStaff.role === 'manager' && <Briefcase size={12} />}
|
||||
{editingStaff.role}
|
||||
</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>
|
||||
{/* Role Section */}
|
||||
{editingStaff.role !== 'owner' && staffRoles.length > 0 && (
|
||||
<div className="pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-3 flex items-center gap-2">
|
||||
<Shield size={16} />
|
||||
{t('staff.staffRole', 'Staff Role')}
|
||||
</h4>
|
||||
|
||||
{/* Staff Role Selector */}
|
||||
<div>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Permissions - Using shared component */}
|
||||
{editingStaff.role === 'manager' && (
|
||||
<StaffPermissions
|
||||
role="manager"
|
||||
permissions={editPermissions}
|
||||
onChange={setEditPermissions}
|
||||
variant="edit"
|
||||
/>
|
||||
)}
|
||||
|
||||
{editingStaff.role === 'staff' && (
|
||||
<StaffPermissions
|
||||
role="staff"
|
||||
permissions={editPermissions}
|
||||
onChange={setEditPermissions}
|
||||
variant="edit"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* No permissions for owners */}
|
||||
{/* Owner info banner */}
|
||||
{editingStaff.role === 'owner' && (
|
||||
<div className="p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg border border-purple-200 dark:border-purple-800">
|
||||
<p className="text-sm text-purple-700 dark:text-purple-300">
|
||||
{t('staff.ownerFullAccess')}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield size={16} className="text-purple-600 dark:text-purple-400" />
|
||||
<p className="text-sm text-purple-700 dark:text-purple-300">
|
||||
{t('staff.ownerFullAccess')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Account Security Section - Password Reset */}
|
||||
{editingStaff.role !== 'owner' && (
|
||||
<div className="pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-3 flex items-center gap-2">
|
||||
<Key size={16} />
|
||||
{t('staff.accountSecurity', 'Account Security')}
|
||||
</h4>
|
||||
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{t('staff.resetPassword', 'Reset Password')}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{t('staff.resetPasswordHint', 'Send a password reset email to this staff member')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSendPasswordReset}
|
||||
disabled={passwordResetMutation.isPending}
|
||||
className="ml-4 px-3 py-1.5 text-xs font-medium rounded-lg transition-colors flex items-center gap-1.5 flex-shrink-0 text-brand-600 border border-brand-300 hover:bg-brand-50 dark:text-brand-400 dark:border-brand-700 dark:hover:bg-brand-900/30"
|
||||
>
|
||||
{passwordResetMutation.isPending ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
<Mail size={14} />
|
||||
)}
|
||||
{t('staff.sendResetEmail', 'Send Reset Email')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -838,29 +972,69 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeEditModal}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
{editingStaff.role !== 'owner' && (
|
||||
<button
|
||||
onClick={handleSaveStaffSettings}
|
||||
disabled={updateStaffMutation.isPending || !!editSuccess}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-brand-600 rounded-lg hover:bg-brand-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
{updateStaffMutation.isPending ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
) : null}
|
||||
{t('common.save')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* Action Buttons - Fixed footer */}
|
||||
<div className="flex justify-end gap-3 px-6 py-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50 flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeEditModal}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveStaffSettings}
|
||||
disabled={updateStaffMutation.isPending || !!editSuccess}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-brand-600 rounded-lg hover:bg-brand-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
{updateStaffMutation.isPending ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
) : null}
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
)}
|
||||
|
||||
{/* Verify Email Confirmation Modal */}
|
||||
{verifyEmailTarget && (
|
||||
<Portal>
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-sm overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{verifyEmailTarget.email_verified ? t('staff.unverifyEmailTitle') : t('staff.verifyEmailTitle')}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{verifyEmailTarget.email_verified
|
||||
? t('staff.unverifyEmailConfirm', { email: verifyEmailTarget.email })
|
||||
: t('staff.verifyEmailConfirm', { email: verifyEmailTarget.email })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50 flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => setVerifyEmailTarget(null)}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleVerifyEmailConfirm}
|
||||
disabled={verifyEmailMutation.isPending}
|
||||
className={`px-4 py-2 text-sm font-medium text-white rounded-lg disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 ${
|
||||
verifyEmailTarget.email_verified
|
||||
? 'bg-amber-600 hover:bg-amber-700'
|
||||
: 'bg-green-600 hover:bg-green-700'
|
||||
}`}
|
||||
>
|
||||
{verifyEmailMutation.isPending && <Loader2 size={16} className="animate-spin" />}
|
||||
{verifyEmailTarget.email_verified ? t('staff.unverifyEmail') : t('staff.verifyEmail')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -122,7 +122,9 @@ const Tickets: React.FC = () => {
|
||||
setIsTicketModalOpen(false);
|
||||
};
|
||||
|
||||
const isOwnerOrManager = currentUser?.role === 'owner' || currentUser?.role === 'manager';
|
||||
// Owner and staff with can_access_tickets permission have full access
|
||||
const hasFullTicketAccess = currentUser?.role === 'owner' ||
|
||||
(currentUser?.role === 'staff' && currentUser?.effective_permissions?.can_access_tickets);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -163,7 +165,7 @@ const Tickets: React.FC = () => {
|
||||
{t('tickets.title', 'Support Tickets')}
|
||||
</h2>
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
{isOwnerOrManager
|
||||
{hasFullTicketAccess
|
||||
? t('tickets.descriptionOwner', 'Manage support tickets for your business')
|
||||
: t('tickets.descriptionStaff', 'View and create support tickets')}
|
||||
</p>
|
||||
|
||||
@@ -21,13 +21,14 @@ const ApiSettings: React.FC = () => {
|
||||
}>();
|
||||
|
||||
const isOwner = user.role === 'owner';
|
||||
const hasPermission = isOwner || user.effective_permissions?.can_access_settings_api === true;
|
||||
const { canUse } = usePlanFeatures();
|
||||
|
||||
if (!isOwner) {
|
||||
if (!hasPermission) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
Only the business owner can access these settings.
|
||||
You do not have permission to access these settings.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -59,6 +59,7 @@ const AuthenticationSettings: React.FC = () => {
|
||||
const [showToast, setShowToast] = useState(false);
|
||||
|
||||
const isOwner = user.role === 'owner';
|
||||
const hasPermission = isOwner || user.effective_permissions?.can_access_settings_authentication === true;
|
||||
const { canUse } = usePlanFeatures();
|
||||
|
||||
// Update OAuth settings when data loads
|
||||
@@ -147,11 +148,11 @@ const AuthenticationSettings: React.FC = () => {
|
||||
setShowSecrets((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||
};
|
||||
|
||||
if (!isOwner) {
|
||||
if (!hasPermission) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
Only the business owner can access these settings.
|
||||
You do not have permission to access these settings.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -26,6 +26,7 @@ const BookingSettings: React.FC = () => {
|
||||
const [returnUrlSaving, setReturnUrlSaving] = useState(false);
|
||||
|
||||
const isOwner = user.role === 'owner';
|
||||
const hasPermission = isOwner || user.effective_permissions?.can_access_settings_booking === true;
|
||||
|
||||
const handleSaveReturnUrl = async () => {
|
||||
setReturnUrlSaving(true);
|
||||
@@ -40,11 +41,11 @@ const BookingSettings: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOwner) {
|
||||
if (!hasPermission) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
{t('settings.booking.onlyOwnerCanAccess', 'Only the business owner can access these settings.')}
|
||||
{t('settings.noPermission', 'You do not have permission to access these settings.')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -139,12 +139,13 @@ const BrandingSettings: React.FC = () => {
|
||||
};
|
||||
|
||||
const isOwner = user.role === 'owner';
|
||||
const hasPermission = isOwner || user.effective_permissions?.can_access_settings_branding === true;
|
||||
|
||||
if (!isOwner) {
|
||||
if (!hasPermission) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
Only the business owner can access these settings.
|
||||
You do not have permission to access these settings.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,9 +6,10 @@
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useOutletContext } from 'react-router-dom';
|
||||
import { useTimeBlocks, useCreateTimeBlock, useUpdateTimeBlock, useDeleteTimeBlock } from '../../hooks/useTimeBlocks';
|
||||
import { Button, FormInput, Alert, LoadingSpinner, Card } from '../../components/ui';
|
||||
import { BlockPurpose, TimeBlock } from '../../types';
|
||||
import { BlockPurpose, TimeBlock, Business, User } from '../../types';
|
||||
|
||||
interface DayHours {
|
||||
enabled: boolean;
|
||||
@@ -58,11 +59,19 @@ const DEFAULT_HOURS: BusinessHours = {
|
||||
};
|
||||
|
||||
const BusinessHoursSettings: React.FC = () => {
|
||||
const { user } = useOutletContext<{
|
||||
business: Business;
|
||||
user: User;
|
||||
}>();
|
||||
|
||||
const [hours, setHours] = useState<BusinessHours>(DEFAULT_HOURS);
|
||||
const [error, setError] = useState<string>('');
|
||||
const [success, setSuccess] = useState<string>('');
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const isOwner = user.role === 'owner';
|
||||
const hasPermission = isOwner || user.effective_permissions?.can_access_settings_business_hours === true;
|
||||
|
||||
// Fetch existing business hours time blocks
|
||||
const { data: timeBlocks, isLoading } = useTimeBlocks({
|
||||
purpose: 'BUSINESS_HOURS' as BlockPurpose,
|
||||
@@ -248,6 +257,16 @@ const BusinessHoursSettings: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
if (!hasPermission) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
You do not have permission to access these settings.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
|
||||
@@ -91,6 +91,7 @@ const CommunicationSettings: React.FC = () => {
|
||||
const [wizardAvailableNumbers, setWizardAvailableNumbers] = useState<AvailablePhoneNumber[]>([]);
|
||||
|
||||
const isOwner = user.role === 'owner';
|
||||
const hasPermission = isOwner || user.effective_permissions?.can_access_settings_sms_calling === true;
|
||||
const { canUse } = usePlanFeatures();
|
||||
|
||||
// Update settings form when credits data loads
|
||||
@@ -249,11 +250,11 @@ const CommunicationSettings: React.FC = () => {
|
||||
setWizardStep(4);
|
||||
};
|
||||
|
||||
if (!isOwner) {
|
||||
if (!hasPermission) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
Only the business owner can access these settings.
|
||||
You do not have permission to access these settings.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -43,6 +43,7 @@ const CustomDomainsSettings: React.FC = () => {
|
||||
const [showToast, setShowToast] = useState(false);
|
||||
|
||||
const isOwner = user.role === 'owner';
|
||||
const hasPermission = isOwner || user.effective_permissions?.can_access_settings_custom_domains === true;
|
||||
const { canUse } = usePlanFeatures();
|
||||
|
||||
const handleAddDomain = () => {
|
||||
@@ -104,11 +105,11 @@ const CustomDomainsSettings: React.FC = () => {
|
||||
});
|
||||
};
|
||||
|
||||
if (!isOwner) {
|
||||
if (!hasPermission) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
Only the business owner can access these settings.
|
||||
You do not have permission to access these settings.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -19,12 +19,13 @@ const EmailSettings: React.FC = () => {
|
||||
}>();
|
||||
|
||||
const isOwner = user.role === 'owner';
|
||||
const hasPermission = isOwner || user.effective_permissions?.can_access_settings_email === true;
|
||||
|
||||
if (!isOwner) {
|
||||
if (!hasPermission) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
Only the business owner can access these settings.
|
||||
You do not have permission to access these settings.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -37,6 +37,7 @@ const EmbedWidgetSettings: React.FC = () => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const isOwner = user.role === 'owner';
|
||||
const hasPermission = isOwner || user.effective_permissions?.can_access_settings_embed_widget === true;
|
||||
|
||||
// Build the embed URL
|
||||
const embedUrl = useMemo(() => {
|
||||
@@ -86,11 +87,11 @@ const EmbedWidgetSettings: React.FC = () => {
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
if (!isOwner) {
|
||||
if (!hasPermission) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
{t('settings.embedWidget.onlyOwnerCanAccess', 'Only the business owner can access these settings.')}
|
||||
{t('settings.noPermission', 'You do not have permission to access these settings.')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -170,12 +170,13 @@ const GeneralSettings: React.FC = () => {
|
||||
};
|
||||
|
||||
const isOwner = user.role === 'owner';
|
||||
const hasPermission = isOwner || user.effective_permissions?.can_access_settings_general === true;
|
||||
|
||||
if (!isOwner) {
|
||||
if (!hasPermission) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
{t('settings.ownerOnly', 'Only the business owner can access these settings.')}
|
||||
{t('settings.noPermission', 'You do not have permission to access these settings.')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -33,6 +33,7 @@ const ResourceTypesSettings: React.FC = () => {
|
||||
});
|
||||
|
||||
const isOwner = user.role === 'owner';
|
||||
const hasPermission = isOwner || user.effective_permissions?.can_access_settings_resource_types === true;
|
||||
|
||||
const openCreateModal = () => {
|
||||
setEditingType(null);
|
||||
@@ -83,11 +84,11 @@ const ResourceTypesSettings: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOwner) {
|
||||
if (!hasPermission) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
Only the business owner can access these settings.
|
||||
You do not have permission to access these settings.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -41,14 +41,15 @@ const StaffRolesSettings: React.FC = () => {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const isOwner = user.role === 'owner';
|
||||
const isManager = user.role === 'manager';
|
||||
const canManageRoles = isOwner || isManager;
|
||||
// Only owners can manage roles (staff with permissions can view but not edit)
|
||||
const canManageRoles = isOwner;
|
||||
|
||||
// Merge menu and dangerous permissions for display
|
||||
// Merge menu, settings, and dangerous permissions for display
|
||||
const allPermissions = useMemo(() => {
|
||||
if (!availablePermissions) return { menu: {}, dangerous: {} };
|
||||
if (!availablePermissions) return { menu: {}, settings: {}, dangerous: {} };
|
||||
return {
|
||||
menu: availablePermissions.menu_permissions || {},
|
||||
settings: availablePermissions.settings_permissions || {},
|
||||
dangerous: availablePermissions.dangerous_permissions || {},
|
||||
};
|
||||
}, [availablePermissions]);
|
||||
@@ -82,21 +83,50 @@ const StaffRolesSettings: React.FC = () => {
|
||||
};
|
||||
|
||||
const togglePermission = (key: string) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
permissions: {
|
||||
...prev.permissions,
|
||||
[key]: !prev.permissions[key],
|
||||
},
|
||||
}));
|
||||
setFormData((prev) => {
|
||||
const newValue = !prev.permissions[key];
|
||||
const updates: Record<string, boolean> = { [key]: newValue };
|
||||
|
||||
// If enabling any settings sub-permission, also enable the main settings access
|
||||
if (newValue && key.startsWith('can_access_settings_')) {
|
||||
updates['can_access_settings'] = true;
|
||||
}
|
||||
|
||||
// If disabling the main settings access, disable all sub-permissions
|
||||
if (!newValue && key === 'can_access_settings') {
|
||||
Object.keys(allPermissions.settings).forEach((settingKey) => {
|
||||
if (settingKey !== 'can_access_settings') {
|
||||
updates[settingKey] = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
permissions: {
|
||||
...prev.permissions,
|
||||
...updates,
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const toggleAllPermissions = (category: 'menu' | 'dangerous', enable: boolean) => {
|
||||
const permissions = category === 'menu' ? allPermissions.menu : allPermissions.dangerous;
|
||||
const toggleAllPermissions = (category: 'menu' | 'settings' | 'dangerous', enable: boolean) => {
|
||||
const permissions = category === 'menu'
|
||||
? allPermissions.menu
|
||||
: category === 'settings'
|
||||
? allPermissions.settings
|
||||
: allPermissions.dangerous;
|
||||
const updates: Record<string, boolean> = {};
|
||||
Object.keys(permissions).forEach((key) => {
|
||||
updates[key] = enable;
|
||||
});
|
||||
|
||||
// If enabling any settings permissions, ensure main settings access is also enabled
|
||||
if (category === 'settings' && enable) {
|
||||
updates['can_access_settings'] = true;
|
||||
}
|
||||
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
permissions: {
|
||||
@@ -160,7 +190,7 @@ const StaffRolesSettings: React.FC = () => {
|
||||
<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.')}
|
||||
{t('settings.staffRoles.noAccess', 'Only the business owner can manage staff roles.')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -324,8 +354,7 @@ const StaffRolesSettings: React.FC = () => {
|
||||
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"
|
||||
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"
|
||||
placeholder={t('settings.staffRoles.roleNamePlaceholder', 'e.g., Front Desk, Senior Stylist')}
|
||||
/>
|
||||
</div>
|
||||
@@ -398,6 +427,60 @@ const StaffRolesSettings: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Business Settings 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.settingsPermissions', 'Business Settings Access')}
|
||||
</h4>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{t('settings.staffRoles.settingsPermissionsDescription', 'Control which settings pages staff can access.')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleAllPermissions('settings', 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('settings', 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-blue-50/50 dark:bg-blue-900/10 rounded-lg border border-blue-100 dark:border-blue-900/30">
|
||||
{Object.entries(allPermissions.settings).map(([key, def]: [string, PermissionDefinition]) => (
|
||||
<label
|
||||
key={key}
|
||||
className="flex items-center gap-2 p-2 rounded-lg hover:bg-blue-100/50 dark:hover:bg-blue-900/20 cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.permissions[key] || false}
|
||||
onChange={() => togglePermission(key)}
|
||||
className="w-4 h-4 text-blue-600 border-gray-300 dark:border-gray-600 rounded focus:ring-blue-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">
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import React, { useState, useCallback, useMemo, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useOutletContext } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Puck, Render } from '@measured/puck';
|
||||
import '@measured/puck/puck.css';
|
||||
@@ -39,6 +40,8 @@ import {
|
||||
SystemEmailTag,
|
||||
SystemEmailCategory,
|
||||
SystemEmailType,
|
||||
Business,
|
||||
User,
|
||||
} from '../../types';
|
||||
|
||||
// Category metadata
|
||||
@@ -86,6 +89,14 @@ const CATEGORY_ORDER: SystemEmailCategory[] = [
|
||||
const SystemEmailTemplates: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const { user } = useOutletContext<{
|
||||
business: Business;
|
||||
user: User;
|
||||
}>();
|
||||
|
||||
const isOwner = user.role === 'owner';
|
||||
const hasPermission = isOwner || user.effective_permissions?.can_access_settings_email_templates === true;
|
||||
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<SystemEmailCategory>>(
|
||||
new Set(CATEGORY_ORDER)
|
||||
);
|
||||
@@ -343,6 +354,16 @@ const SystemEmailTemplates: React.FC = () => {
|
||||
setHasUnsavedChanges(false);
|
||||
};
|
||||
|
||||
if (!hasPermission) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
{t('settings.noPermission', 'You do not have permission to access these settings.')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
|
||||
@@ -94,7 +94,7 @@ export interface Business {
|
||||
planPermissions?: PlanPermissions;
|
||||
}
|
||||
|
||||
export type UserRole = 'superuser' | 'platform_manager' | 'platform_support' | 'owner' | 'manager' | 'staff' | 'resource' | 'customer';
|
||||
export type UserRole = 'superuser' | 'platform_manager' | 'platform_support' | 'owner' | 'staff' | 'resource' | 'customer';
|
||||
|
||||
export interface NotificationPreferences {
|
||||
email: boolean;
|
||||
@@ -163,6 +163,7 @@ export interface PermissionDefinition {
|
||||
|
||||
export interface AvailablePermissions {
|
||||
menu_permissions: Record<string, PermissionDefinition>;
|
||||
settings_permissions: Record<string, PermissionDefinition>;
|
||||
dangerous_permissions: Record<string, PermissionDefinition>;
|
||||
}
|
||||
|
||||
@@ -273,6 +274,14 @@ export interface Customer {
|
||||
userId?: string;
|
||||
paymentMethods: PaymentMethod[];
|
||||
notes?: string;
|
||||
email_verified?: boolean;
|
||||
user_data?: {
|
||||
id: number;
|
||||
username: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Service {
|
||||
|
||||
Reference in New Issue
Block a user