Files
smoothschedule/frontend/src/pages/settings/StaffRolesSettings.tsx
poduck 464726ee3e Update staff roles, documentation, and add tenant API
Staff Roles:
- Remove Support Staff default role (now Manager and Staff only)
- Add position field for custom role ordering
- Update StaffRolesSettings with improved permission UI
- Add RolePermissions component for visual permission display

Documentation Updates:
- HelpStaff: Explain two-tier permission system (User Roles + Staff Roles)
- HelpSettingsStaffRoles: Update default roles, add settings access permissions
- HelpComprehensive: Update staff roles section with correct role structure
- HelpCustomers: Add customer creation and onboarding sections
- HelpContracts: Add lifecycle, snapshotting, and signing experience docs
- HelpSettingsAppearance: Update with 20 color palettes and navigation text

Tenant API:
- Add new isolated API at /tenant-api/v1/ for third-party integrations
- Token-based authentication with scope permissions
- Endpoints: business, services, resources, availability, bookings, customers, webhooks

Tests:
- Add test coverage for Celery tasks across modules
- Reorganize schedule view tests for better maintainability

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 20:46:36 -05:00

453 lines
18 KiB
TypeScript

/**
* Staff Roles Settings Page
*
* Create and manage staff roles with granular permissions.
* Roles control what menu items and features are accessible to staff members.
*/
import React, { useState, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useOutletContext } from 'react-router-dom';
import { Shield, Plus, X, Pencil, Trash2, Users, Lock, Check, GripVertical } from 'lucide-react';
import { Business, User, StaffRole } from '../../types';
import {
useStaffRoles,
useAvailablePermissions,
useCreateStaffRole,
useUpdateStaffRole,
useDeleteStaffRole,
useReorderStaffRoles,
} from '../../hooks/useStaffRoles';
import { RolePermissionsEditor } from '../../components/staff/RolePermissions';
const StaffRolesSettings: React.FC = () => {
const { t } = useTranslation();
const { user } = useOutletContext<{
business: Business;
user: User;
}>();
const { data: staffRoles = [], isLoading } = useStaffRoles();
const { data: availablePermissions } = useAvailablePermissions();
const createStaffRole = useCreateStaffRole();
const updateStaffRole = useUpdateStaffRole();
const deleteStaffRole = useDeleteStaffRole();
const reorderStaffRoles = useReorderStaffRoles();
const [isModalOpen, setIsModalOpen] = useState(false);
const [draggedRoleId, setDraggedRoleId] = useState<number | null>(null);
const [dragOverRoleId, setDragOverRoleId] = useState<number | null>(null);
const draggedRef = useRef<number | null>(null);
const [editingRole, setEditingRole] = useState<StaffRole | null>(null);
const [formData, setFormData] = useState({
name: '',
description: '',
permissions: {} as Record<string, boolean>,
});
const [error, setError] = useState<string | null>(null);
const isOwner = user.role === 'owner';
// Only owners can manage roles (staff with permissions can view but not edit)
const canManageRoles = isOwner;
// Merge menu, settings, and dangerous permissions for display
const allPermissions = useMemo(() => {
if (!availablePermissions) return { menu: {}, settings: {}, dangerous: {} };
return {
menu: availablePermissions.menu_permissions || {},
settings: availablePermissions.settings_permissions || {},
dangerous: availablePermissions.dangerous_permissions || {},
};
}, [availablePermissions]);
const openCreateModal = () => {
setEditingRole(null);
setFormData({
name: '',
description: '',
permissions: {},
});
setError(null);
setIsModalOpen(true);
};
const openEditModal = (role: StaffRole) => {
setEditingRole(role);
setFormData({
name: role.name,
description: role.description || '',
permissions: { ...role.permissions },
});
setError(null);
setIsModalOpen(true);
};
const closeModal = () => {
setIsModalOpen(false);
setEditingRole(null);
setError(null);
};
const handlePermissionsChange = (newPermissions: Record<string, boolean>) => {
setFormData((prev) => ({
...prev,
permissions: newPermissions,
}));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
try {
if (editingRole) {
await updateStaffRole.mutateAsync({
id: editingRole.id,
name: formData.name,
description: formData.description,
permissions: formData.permissions,
});
} else {
await createStaffRole.mutateAsync({
name: formData.name,
description: formData.description,
permissions: formData.permissions,
});
}
closeModal();
} catch (err: any) {
const message = err.response?.data?.error || err.response?.data?.name?.[0] || 'Failed to save role';
setError(message);
}
};
const handleDelete = async (role: StaffRole) => {
if (!role.can_delete) {
alert(
role.is_default
? t('settings.staffRoles.cannotDeleteDefault', 'Default roles cannot be deleted.')
: t('settings.staffRoles.cannotDeleteInUse', 'Remove all staff from this role before deleting.')
);
return;
}
if (window.confirm(t('settings.staffRoles.confirmDelete', `Are you sure you want to delete the "${role.name}" role?`))) {
try {
await deleteStaffRole.mutateAsync(role.id);
} catch (err: any) {
alert(err.response?.data?.error || 'Failed to delete role');
}
}
};
const countEnabledPermissions = (permissions: Record<string, boolean>) => {
return Object.values(permissions).filter(Boolean).length;
};
// Drag and drop handlers
const handleDragStart = (e: React.DragEvent, roleId: number) => {
e.dataTransfer.setData('text/plain', String(roleId));
e.dataTransfer.effectAllowed = 'move';
draggedRef.current = roleId;
setDraggedRoleId(roleId);
};
const handleDragEnd = () => {
setDraggedRoleId(null);
setDragOverRoleId(null);
draggedRef.current = null;
};
const handleDragOver = (e: React.DragEvent, roleId: number) => {
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = 'move';
if (draggedRef.current && draggedRef.current !== roleId) {
setDragOverRoleId(roleId);
}
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragOverRoleId(null);
};
const handleDrop = async (e: React.DragEvent, targetRoleId: number) => {
e.preventDefault();
e.stopPropagation();
const draggedId = draggedRef.current;
setDragOverRoleId(null);
if (!draggedId || draggedId === targetRoleId) {
return;
}
// Reorder the roles
const currentOrder = staffRoles.map(r => r.id);
const draggedIndex = currentOrder.indexOf(draggedId);
const targetIndex = currentOrder.indexOf(targetRoleId);
if (draggedIndex === -1 || targetIndex === -1) {
return;
}
// Remove dragged item and insert at target position
const newOrder = [...currentOrder];
newOrder.splice(draggedIndex, 1);
newOrder.splice(targetIndex, 0, draggedId);
try {
await reorderStaffRoles.mutateAsync(newOrder);
} catch (err) {
// Silently fail - the UI will remain unchanged
}
};
if (!canManageRoles) {
return (
<div className="text-center py-12">
<Shield size={48} className="mx-auto mb-4 text-gray-300 dark:text-gray-600" />
<p className="text-gray-500 dark:text-gray-400">
{t('settings.staffRoles.noAccess', 'Only the business owner can manage staff roles.')}
</p>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-3">
<Shield className="text-indigo-500" />
{t('settings.staffRoles.title', 'Staff Roles')}
</h2>
<p className="text-gray-500 dark:text-gray-400 mt-1">
{t('settings.staffRoles.subtitle', 'Create roles to control what staff members can access in your business.')}
</p>
</div>
{/* Roles List */}
<section className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
<div className="flex items-center justify-between mb-6">
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{t('settings.staffRoles.yourRoles', 'Your Staff Roles')}
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
{t('settings.staffRoles.rolesDescription', 'Assign staff members to roles to control their permissions.')}
</p>
</div>
<button
onClick={openCreateModal}
className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors font-medium text-sm"
>
<Plus size={16} />
{t('settings.staffRoles.createRole', 'Create Role')}
</button>
</div>
{isLoading ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-brand-600"></div>
</div>
) : staffRoles.length === 0 ? (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
<Shield size={40} className="mx-auto mb-2 opacity-30" />
<p>{t('settings.staffRoles.noRoles', 'No staff roles configured.')}</p>
<p className="text-sm mt-1">{t('settings.staffRoles.createFirst', 'Create your first role to manage staff permissions.')}</p>
</div>
) : (
<div
className="space-y-3"
onDragOver={(e) => e.preventDefault()}
>
{staffRoles.map((role) => (
<div
key={role.id}
draggable="true"
onDragStart={(e) => handleDragStart(e, role.id)}
onDragEnd={handleDragEnd}
onDragOver={(e) => handleDragOver(e, role.id)}
onDragLeave={(e) => handleDragLeave(e)}
onDrop={(e) => handleDrop(e, role.id)}
className={`p-4 bg-gray-50 dark:bg-gray-900/50 rounded-lg border transition-all select-none ${
dragOverRoleId === role.id
? 'border-brand-500 border-2 bg-brand-50 dark:bg-brand-900/20'
: draggedRoleId === role.id
? 'border-gray-300 dark:border-gray-600 opacity-50'
: 'border-gray-200 dark:border-gray-700'
}`}
>
<div className="flex items-start justify-between">
<div className="flex items-start gap-3 flex-1 min-w-0">
{/* Drag Handle */}
<div
className="w-6 h-10 flex items-center justify-center shrink-0 cursor-grab active:cursor-grabbing text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 touch-none"
title={t('settings.staffRoles.dragToReorder', 'Drag to reorder')}
>
<GripVertical size={18} />
</div>
<div className={`w-10 h-10 rounded-lg flex items-center justify-center shrink-0 ${
role.is_default
? 'bg-indigo-100 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400'
: 'bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400'
}`}>
<Shield size={20} />
</div>
<div className="flex-1 min-w-0">
<h4 className="font-medium text-gray-900 dark:text-white flex items-center gap-2 flex-wrap">
{role.name}
{role.is_default && (
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-indigo-100 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400 rounded">
{t('common.default', 'Default')}
</span>
)}
</h4>
<p className="text-sm text-gray-500 dark:text-gray-400 flex items-center gap-3 mt-0.5">
<span className="flex items-center gap-1">
<Users size={14} />
{t('settings.staffRoles.staffAssigned', '{{count}} staff', { count: role.staff_count })}
</span>
<span className="flex items-center gap-1">
<Check size={14} />
{t('settings.staffRoles.permissionsEnabled', '{{count}} permissions', {
count: countEnabledPermissions(role.permissions),
})}
</span>
</p>
{role.description && (
<p className="text-sm text-gray-600 dark:text-gray-300 mt-1 line-clamp-2">
{role.description}
</p>
)}
</div>
</div>
<div className="flex items-center gap-2 shrink-0 ml-2" onDragStart={(e) => e.preventDefault()}>
<button
onClick={() => openEditModal(role)}
draggable="false"
className="p-2 text-gray-400 hover:text-brand-600 dark:hover:text-brand-400 transition-colors"
title={t('common.edit', 'Edit')}
>
<Pencil size={16} />
</button>
<button
onClick={() => handleDelete(role)}
draggable="false"
disabled={deleteStaffRole.isPending || !role.can_delete}
className={`p-2 transition-colors disabled:opacity-50 ${
role.can_delete
? 'text-gray-400 hover:text-red-600 dark:hover:text-red-400'
: 'text-gray-300 dark:text-gray-600 cursor-not-allowed'
}`}
title={
role.is_default
? t('settings.staffRoles.cannotDeleteDefault', 'Default roles cannot be deleted')
: role.staff_count > 0
? t('settings.staffRoles.cannotDeleteInUse', 'Remove all staff first')
: t('common.delete', 'Delete')
}
>
{role.can_delete ? <Trash2 size={16} /> : <Lock size={16} />}
</button>
</div>
</div>
</div>
))}
</div>
)}
</section>
{/* Create/Edit Modal */}
{isModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-2xl w-full max-h-[90vh] overflow-hidden flex flex-col">
<div className="flex items-center justify-between p-6 border-b border-gray-100 dark:border-gray-700 shrink-0">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{editingRole
? t('settings.staffRoles.editRole', 'Edit Role')
: t('settings.staffRoles.createRole', 'Create Role')}
</h3>
<button
onClick={closeModal}
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<X className="h-5 w-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="flex-1 overflow-y-auto">
<div className="p-6 space-y-6">
{error && (
<div className="p-3 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 rounded-lg text-sm">
{error}
</div>
)}
{/* Basic Info */}
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('settings.staffRoles.roleName', 'Role Name')} *
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
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>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('settings.staffRoles.roleDescription', 'Description')}
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={2}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500 resize-none"
placeholder={t('settings.staffRoles.roleDescriptionPlaceholder', 'Describe what this role can do...')}
/>
</div>
</div>
{/* Permissions Editor */}
<RolePermissionsEditor
permissions={formData.permissions}
onChange={handlePermissionsChange}
availablePermissions={allPermissions}
/>
</div>
<div className="flex justify-end gap-3 p-6 border-t border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50 shrink-0">
<button
type="button"
onClick={closeModal}
className="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
{t('common.cancel', 'Cancel')}
</button>
<button
type="submit"
disabled={createStaffRole.isPending || updateStaffRole.isPending}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{editingRole ? t('common.save', 'Save') : t('common.create', 'Create')}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
};
export default StaffRolesSettings;