This commit adds major features for sandbox isolation, public API access, and platform support ticketing. ## Sandbox Mode - Add sandbox mode toggle for businesses to test features without affecting live data - Implement schema-based isolation for tenant data (appointments, resources, services) - Add is_sandbox field filtering for shared models (customers, staff, tickets) - Create sandbox middleware to detect and set sandbox mode from cookies - Add sandbox context and hooks for React frontend - Display sandbox banner when in test mode - Auto-reload page when switching between live/test modes - Prevent platform support tickets from being created in sandbox mode ## Public API System - Full REST API for external integrations with businesses - API token management with sandbox/live token separation - Test tokens (ss_test_*) show full plaintext for easy testing - Live tokens (ss_live_*) are hashed and secure - Security validation prevents live token plaintext storage - Comprehensive test suite for token security - Rate limiting and throttling per token - Webhook support for real-time event notifications - Scoped permissions system (read/write per resource type) - API documentation page with interactive examples - Token revocation with confirmation modal ## Platform Support - Dedicated support page for businesses to contact SmoothSchedule - View all platform support tickets in one place - Create new support tickets with simplified interface - Reply to existing tickets with conversation history - Platform tickets have no admin controls (no priority/category/assignee/status) - Internal notes hidden for platform tickets (business can't see them) - Quick help section with links to guides and API docs - Sandbox warning prevents ticket creation in test mode - Business ticketing retains full admin controls (priority, assignment, internal notes) ## UI/UX Improvements - Add notification dropdown with real-time updates - Staff permissions UI for ticket access control - Help dropdown in sidebar with Platform Guide, Ticketing Help, API Docs, and Support - Update sidebar "Contact Support" to "Support" with message icon - Fix navigation links to use React Router instead of anchor tags - Remove unused language translations (Japanese, Portuguese, Chinese) ## Technical Details - Sandbox middleware sets request.sandbox_mode from cookies - ViewSets filter data by is_sandbox field - API authentication via custom token auth class - WebSocket support for real-time ticket updates - Migration for sandbox fields on User, Tenant, and Ticket models - Comprehensive documentation in SANDBOX_MODE_IMPLEMENTATION.md 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
795 lines
37 KiB
TypeScript
795 lines
37 KiB
TypeScript
import React, { useState } 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 {
|
|
useInvitations,
|
|
useCreateInvitation,
|
|
useCancelInvitation,
|
|
useResendInvitation,
|
|
StaffInvitation,
|
|
CreateInvitationData,
|
|
} from '../hooks/useInvitations';
|
|
import {
|
|
Plus,
|
|
User as UserIcon,
|
|
Shield,
|
|
Briefcase,
|
|
Calendar,
|
|
X,
|
|
Mail,
|
|
Clock,
|
|
Loader2,
|
|
Send,
|
|
Trash2,
|
|
RefreshCw,
|
|
Pencil,
|
|
ChevronDown,
|
|
ChevronRight,
|
|
UserX,
|
|
Power,
|
|
} from 'lucide-react';
|
|
import Portal from '../components/Portal';
|
|
import StaffPermissions from '../components/StaffPermissions';
|
|
|
|
interface StaffProps {
|
|
onMasquerade: (user: User) => void;
|
|
effectiveUser: User;
|
|
}
|
|
|
|
const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
|
const { t } = useTranslation();
|
|
const { data: staffMembers = [], isLoading, error } = useStaff();
|
|
const { data: resources = [] } = useResources();
|
|
const { data: invitations = [], isLoading: invitationsLoading } = useInvitations();
|
|
const createResourceMutation = useCreateResource();
|
|
const createInvitationMutation = useCreateInvitation();
|
|
const cancelInvitationMutation = useCancelInvitation();
|
|
const resendInvitationMutation = useResendInvitation();
|
|
const toggleActiveMutation = useToggleStaffActive();
|
|
const updateStaffMutation = useUpdateStaff();
|
|
|
|
const [isInviteModalOpen, setIsInviteModalOpen] = useState(false);
|
|
const [inviteEmail, setInviteEmail] = useState('');
|
|
const [inviteRole, setInviteRole] = useState<'TENANT_MANAGER' | 'TENANT_STAFF'>('TENANT_STAFF');
|
|
const [createBookableResource, setCreateBookableResource] = useState(false);
|
|
const [resourceName, setResourceName] = useState('');
|
|
const [invitePermissions, setInvitePermissions] = useState<Record<string, boolean>>({});
|
|
const [inviteError, setInviteError] = useState('');
|
|
const [inviteSuccess, setInviteSuccess] = useState('');
|
|
const [showInactiveStaff, setShowInactiveStaff] = useState(false);
|
|
|
|
// Edit modal state
|
|
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
|
const [editingStaff, setEditingStaff] = useState<StaffMember | null>(null);
|
|
const [editPermissions, setEditPermissions] = useState<Record<string, boolean>>({});
|
|
const [editError, setEditError] = useState('');
|
|
const [editSuccess, setEditSuccess] = useState('');
|
|
|
|
// Check if user can invite managers (only owners can)
|
|
const canInviteManagers = effectiveUser.role === 'owner';
|
|
|
|
// 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
|
|
const getLinkedResource = (userId: string) => {
|
|
return resources.find((r: any) => r.user_id === parseInt(userId));
|
|
};
|
|
|
|
const handleMakeBookable = (user: any) => {
|
|
if (confirm(`Create a bookable resource for ${user.name || user.username}?`)) {
|
|
createResourceMutation.mutate({
|
|
name: user.name || user.username,
|
|
type: 'STAFF',
|
|
user_id: user.id,
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleInviteSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setInviteError('');
|
|
setInviteSuccess('');
|
|
|
|
if (!inviteEmail.trim()) {
|
|
setInviteError(t('staff.emailRequired', 'Email is required'));
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const invitationData: CreateInvitationData = {
|
|
email: inviteEmail.trim().toLowerCase(),
|
|
role: inviteRole,
|
|
create_bookable_resource: createBookableResource,
|
|
resource_name: resourceName.trim(),
|
|
permissions: invitePermissions,
|
|
};
|
|
|
|
await createInvitationMutation.mutateAsync(invitationData);
|
|
setInviteSuccess(t('staff.invitationSent', 'Invitation sent successfully!'));
|
|
setInviteEmail('');
|
|
setCreateBookableResource(false);
|
|
setResourceName('');
|
|
setInvitePermissions({});
|
|
// Close modal after short delay
|
|
setTimeout(() => {
|
|
setIsInviteModalOpen(false);
|
|
setInviteSuccess('');
|
|
}, 1500);
|
|
} catch (err: any) {
|
|
setInviteError(err.response?.data?.error || t('staff.invitationFailed', 'Failed to send invitation'));
|
|
}
|
|
};
|
|
|
|
const handleCancelInvitation = async (invitation: StaffInvitation) => {
|
|
if (confirm(t('staff.confirmCancelInvitation', `Cancel invitation to ${invitation.email}?`))) {
|
|
try {
|
|
await cancelInvitationMutation.mutateAsync(invitation.id);
|
|
} catch (err: any) {
|
|
alert(err.response?.data?.error || t('staff.cancelFailed', 'Failed to cancel invitation'));
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleResendInvitation = async (invitation: StaffInvitation) => {
|
|
try {
|
|
await resendInvitationMutation.mutateAsync(invitation.id);
|
|
alert(t('staff.invitationResent', 'Invitation resent successfully!'));
|
|
} catch (err: any) {
|
|
alert(err.response?.data?.error || t('staff.resendFailed', 'Failed to resend invitation'));
|
|
}
|
|
};
|
|
|
|
const openInviteModal = () => {
|
|
setInviteEmail('');
|
|
setInviteRole('TENANT_STAFF');
|
|
setCreateBookableResource(false);
|
|
setResourceName('');
|
|
setInvitePermissions({});
|
|
setInviteError('');
|
|
setInviteSuccess('');
|
|
setIsInviteModalOpen(true);
|
|
};
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="p-8 max-w-7xl mx-auto">
|
|
<div className="flex items-center justify-center py-12">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-brand-600"></div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="p-8 max-w-7xl mx-auto">
|
|
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
|
<p className="text-red-800 dark:text-red-300">
|
|
{t('staff.errorLoading')}: {(error as Error).message}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const handleToggleActive = async (user: any) => {
|
|
const action = user.is_active ? 'deactivate' : 'reactivate';
|
|
if (confirm(t('staff.confirmToggleActive', `Are you sure you want to ${action} ${user.name}?`))) {
|
|
try {
|
|
await toggleActiveMutation.mutateAsync(user.id);
|
|
} catch (err: any) {
|
|
alert(err.response?.data?.error || t('staff.toggleFailed', `Failed to ${action} staff member`));
|
|
}
|
|
}
|
|
};
|
|
|
|
const openEditModal = (staff: StaffMember) => {
|
|
setEditingStaff(staff);
|
|
setEditPermissions(staff.permissions || {});
|
|
setEditError('');
|
|
setEditSuccess('');
|
|
setIsEditModalOpen(true);
|
|
};
|
|
|
|
const closeEditModal = () => {
|
|
setIsEditModalOpen(false);
|
|
setEditingStaff(null);
|
|
setEditPermissions({});
|
|
setEditError('');
|
|
setEditSuccess('');
|
|
};
|
|
|
|
const handleSaveStaffSettings = async () => {
|
|
if (!editingStaff) return;
|
|
|
|
setEditError('');
|
|
try {
|
|
await updateStaffMutation.mutateAsync({
|
|
id: editingStaff.id,
|
|
updates: { permissions: editPermissions },
|
|
});
|
|
setEditSuccess(t('staff.settingsSaved', 'Settings saved successfully'));
|
|
setTimeout(() => {
|
|
closeEditModal();
|
|
}, 1000);
|
|
} catch (err: any) {
|
|
setEditError(err.response?.data?.error || t('staff.saveFailed', 'Failed to save settings'));
|
|
}
|
|
};
|
|
|
|
const handleDeactivateFromModal = async () => {
|
|
if (!editingStaff) return;
|
|
|
|
const action = editingStaff.is_active ? 'deactivate' : 'reactivate';
|
|
if (confirm(t('staff.confirmToggleActive', `Are you sure you want to ${action} ${editingStaff.name}?`))) {
|
|
try {
|
|
await toggleActiveMutation.mutateAsync(editingStaff.id);
|
|
closeEditModal();
|
|
} catch (err: any) {
|
|
setEditError(err.response?.data?.error || t('staff.toggleFailed', `Failed to ${action} staff member`));
|
|
}
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="p-8 max-w-7xl mx-auto space-y-6">
|
|
{/* Header */}
|
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
|
<div>
|
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">{t('staff.title')}</h2>
|
|
<p className="text-gray-500 dark:text-gray-400">{t('staff.description')}</p>
|
|
</div>
|
|
<button
|
|
onClick={openInviteModal}
|
|
className="flex items-center justify-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors shadow-sm font-medium"
|
|
>
|
|
<Plus size={18} />
|
|
{t('staff.inviteStaff')}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Pending Invitations */}
|
|
{invitations.length > 0 && (
|
|
<div className="bg-amber-50 dark:bg-amber-900/20 rounded-xl border border-amber-200 dark:border-amber-800 p-4">
|
|
<h3 className="text-sm font-semibold text-amber-800 dark:text-amber-300 mb-3 flex items-center gap-2">
|
|
<Clock size={16} />
|
|
{t('staff.pendingInvitations', 'Pending Invitations')} ({invitations.length})
|
|
</h3>
|
|
<div className="space-y-2">
|
|
{invitations.map((invitation) => (
|
|
<div
|
|
key={invitation.id}
|
|
className="flex items-center justify-between bg-white dark:bg-gray-800 rounded-lg p-3 border border-amber-200 dark:border-amber-700"
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-8 h-8 rounded-full bg-amber-100 dark:bg-amber-900/50 flex items-center justify-center text-amber-600 dark:text-amber-400">
|
|
<Mail size={16} />
|
|
</div>
|
|
<div>
|
|
<div className="font-medium text-gray-900 dark:text-white text-sm">{invitation.email}</div>
|
|
<div className="text-xs text-gray-500 dark:text-gray-400">
|
|
{invitation.role_display} • {t('staff.expires', 'Expires')}{' '}
|
|
{new Date(invitation.expires_at).toLocaleDateString()}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => handleResendInvitation(invitation)}
|
|
disabled={resendInvitationMutation.isPending}
|
|
className="p-1.5 text-gray-400 hover:text-brand-600 dark:hover:text-brand-400 transition-colors"
|
|
title={t('staff.resendInvitation', 'Resend invitation')}
|
|
>
|
|
<RefreshCw size={16} className={resendInvitationMutation.isPending ? 'animate-spin' : ''} />
|
|
</button>
|
|
<button
|
|
onClick={() => handleCancelInvitation(invitation)}
|
|
disabled={cancelInvitationMutation.isPending}
|
|
className="p-1.5 text-gray-400 hover:text-red-600 dark:hover:text-red-400 transition-colors"
|
|
title={t('staff.cancelInvitation', 'Cancel invitation')}
|
|
>
|
|
<Trash2 size={16} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Table */}
|
|
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden transition-colors duration-200">
|
|
<div className="overflow-x-auto">
|
|
<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.bookableResource')}</th>
|
|
<th className="px-6 py-4 font-medium text-right">{t('common.actions')}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
|
|
{activeStaff.map((user: any) => {
|
|
const linkedResource = getLinkedResource(user.id);
|
|
|
|
// Only owners can masquerade as staff (per backend permissions)
|
|
const canMasquerade = effectiveUser.role === 'owner' && user.id !== effectiveUser.id;
|
|
// Owners can deactivate anyone except themselves
|
|
const canDeactivate = effectiveUser.role === 'owner' && user.id !== effectiveUser.id && user.role !== 'owner';
|
|
|
|
return (
|
|
<tr key={user.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors group">
|
|
<td className="px-6 py-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-8 h-8 rounded-full bg-brand-100 dark:bg-brand-900/50 flex items-center justify-center text-brand-600 dark:text-brand-400 font-medium">
|
|
{user.name ? user.name.charAt(0).toUpperCase() : user.email.charAt(0).toUpperCase()}
|
|
</div>
|
|
<div>
|
|
<div className="font-medium text-gray-900 dark:text-white">{user.name || user.email}</div>
|
|
<div className="text-xs text-gray-500 dark:text-gray-400">{user.email}</div>
|
|
</div>
|
|
</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 ${
|
|
user.role === 'owner'
|
|
? 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300'
|
|
: user.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'
|
|
}`}
|
|
>
|
|
{user.role === 'owner' && <Shield size={12} />}
|
|
{user.role === 'manager' && <Briefcase size={12} />}
|
|
{user.role}
|
|
</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">
|
|
<Calendar size={12} />
|
|
{t('staff.yes')} ({linkedResource.name})
|
|
</span>
|
|
) : (
|
|
<button
|
|
onClick={() => handleMakeBookable(user)}
|
|
className="text-xs text-brand-600 hover:text-brand-700 dark:text-brand-400 hover:underline"
|
|
>
|
|
{t('staff.makeBookable')}
|
|
</button>
|
|
)}
|
|
</td>
|
|
<td className="px-6 py-4 text-right">
|
|
<div className="flex items-center justify-end gap-2">
|
|
{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"
|
|
>
|
|
{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', 'Edit')}
|
|
>
|
|
<Pencil size={16} />
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
{activeStaff.length === 0 && (
|
|
<div className="p-12 text-center">
|
|
<UserIcon size={40} className="mx-auto mb-2 text-gray-300 dark:text-gray-600" />
|
|
<p className="text-gray-500 dark:text-gray-400">{t('staff.noStaffFound', 'No staff members found')}</p>
|
|
<p className="text-sm text-gray-400 dark:text-gray-500 mt-1">
|
|
{t('staff.inviteFirstStaff', 'Invite your first team member to get started')}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Inactive Staff Section */}
|
|
{inactiveStaff.length > 0 && (
|
|
<div className="bg-gray-100 dark:bg-gray-800/50 rounded-xl border border-gray-200 dark:border-gray-700">
|
|
<button
|
|
onClick={() => setShowInactiveStaff(!showInactiveStaff)}
|
|
className="w-full px-4 py-3 flex items-center justify-between text-left hover:bg-gray-200 dark:hover:bg-gray-700/50 rounded-xl transition-colors"
|
|
>
|
|
<div className="flex items-center gap-2 text-gray-600 dark:text-gray-400">
|
|
{showInactiveStaff ? <ChevronDown size={18} /> : <ChevronRight size={18} />}
|
|
<UserX size={18} />
|
|
<span className="font-medium">
|
|
{t('staff.inactiveStaff', 'Inactive Staff')} ({inactiveStaff.length})
|
|
</span>
|
|
</div>
|
|
</button>
|
|
|
|
{showInactiveStaff && (
|
|
<div className="border-t border-gray-200 dark:border-gray-700">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm text-left">
|
|
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
|
{inactiveStaff.map((user: any) => {
|
|
const linkedResource = getLinkedResource(user.id);
|
|
|
|
return (
|
|
<tr key={user.id} className="hover:bg-gray-200/50 dark:hover:bg-gray-700/30 transition-colors opacity-60">
|
|
<td className="px-6 py-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-8 h-8 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center text-gray-500 dark:text-gray-400 font-medium">
|
|
{user.name ? user.name.charAt(0).toUpperCase() : user.email.charAt(0).toUpperCase()}
|
|
</div>
|
|
<div>
|
|
<div className="font-medium text-gray-600 dark:text-gray-400">{user.name || user.email}</div>
|
|
<div className="text-xs text-gray-500 dark:text-gray-500">{user.email}</div>
|
|
</div>
|
|
</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">
|
|
{user.role === 'owner' && <Shield size={12} />}
|
|
{user.role === 'manager' && <Briefcase size={12} />}
|
|
{user.role}
|
|
</span>
|
|
</td>
|
|
<td className="px-6 py-4">
|
|
{linkedResource ? (
|
|
<span className="inline-flex items-center gap-1 text-xs font-medium text-gray-500 dark:text-gray-500 bg-gray-200 dark:bg-gray-700 px-2 py-1 rounded">
|
|
<Calendar size={12} />
|
|
{linkedResource.name}
|
|
</span>
|
|
) : (
|
|
<span className="text-xs text-gray-400">-</span>
|
|
)}
|
|
</td>
|
|
<td className="px-6 py-4 text-right">
|
|
<button
|
|
onClick={() => handleToggleActive(user)}
|
|
disabled={toggleActiveMutation.isPending}
|
|
className="text-green-600 hover:text-green-500 dark:text-green-400 dark:hover:text-green-300 font-medium text-xs inline-flex items-center gap-1 px-3 py-1 border border-green-200 dark:border-green-800 rounded-lg hover:bg-green-50 dark:hover:bg-green-900/30 transition-colors"
|
|
>
|
|
<Power size={14} />
|
|
{t('staff.reactivate', 'Reactivate')}
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Invite Modal */}
|
|
{isInviteModalOpen && (
|
|
<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">
|
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">{t('staff.inviteStaff')}</h3>
|
|
<button
|
|
onClick={() => setIsInviteModalOpen(false)}
|
|
className="text-gray-400 hover:text-gray-500 dark:hover:text-gray-300"
|
|
>
|
|
<X size={24} />
|
|
</button>
|
|
</div>
|
|
|
|
<form onSubmit={handleInviteSubmit} className="p-6 space-y-4">
|
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
|
{t(
|
|
'staff.inviteDescription',
|
|
"Enter the email address of the person you'd like to invite. They'll receive an email with instructions to join your team."
|
|
)}
|
|
</p>
|
|
|
|
{/* Email Input */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
{t('staff.emailAddress', 'Email Address')} *
|
|
</label>
|
|
<input
|
|
type="email"
|
|
value={inviteEmail}
|
|
onChange={(e) => setInviteEmail(e.target.value)}
|
|
placeholder={t('staff.emailPlaceholder', 'colleague@example.com')}
|
|
required
|
|
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"
|
|
/>
|
|
</div>
|
|
|
|
{/* Role Selector */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
{t('staff.roleLabel', 'Role')} *
|
|
</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', 'Staff Member')}</option>
|
|
{canInviteManagers && (
|
|
<option value="TENANT_MANAGER">{t('staff.roleManager', 'Manager')}</option>
|
|
)}
|
|
</select>
|
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
|
{inviteRole === 'TENANT_MANAGER'
|
|
? t('staff.managerRoleHint', 'Managers can manage staff, resources, and view reports')
|
|
: t('staff.staffRoleHint', 'Staff members can manage their own schedule and appointments')}
|
|
</p>
|
|
</div>
|
|
|
|
{/* 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"
|
|
/>
|
|
)}
|
|
|
|
{/* 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">
|
|
<label className="flex items-center gap-2 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={createBookableResource}
|
|
onChange={(e) => setCreateBookableResource(e.target.checked)}
|
|
className="w-4 h-4 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
|
/>
|
|
<div>
|
|
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
{t('staff.makeBookable', 'Make Bookable')}
|
|
</span>
|
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
|
{t('staff.makeBookableHint', 'Create a bookable resource so customers can schedule appointments with this person')}
|
|
</p>
|
|
</div>
|
|
</label>
|
|
|
|
{/* Resource Name (only shown if bookable is checked) */}
|
|
{createBookableResource && (
|
|
<div className="mt-3">
|
|
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
|
{t('staff.resourceName', 'Display Name (optional)')}
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={resourceName}
|
|
onChange={(e) => setResourceName(e.target.value)}
|
|
placeholder={t('staff.resourceNamePlaceholder', "Defaults to person's name")}
|
|
className="w-full px-3 py-2 text-sm 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"
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Error Message */}
|
|
{inviteError && (
|
|
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
|
<p className="text-sm text-red-600 dark:text-red-400">{inviteError}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Success Message */}
|
|
{inviteSuccess && (
|
|
<div className="p-3 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg">
|
|
<p className="text-sm text-green-600 dark:text-green-400">{inviteSuccess}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Buttons */}
|
|
<div className="flex justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsInviteModalOpen(false)}
|
|
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
|
|
type="submit"
|
|
disabled={createInvitationMutation.isPending || !!inviteSuccess}
|
|
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"
|
|
>
|
|
{createInvitationMutation.isPending ? (
|
|
<Loader2 size={16} className="animate-spin" />
|
|
) : (
|
|
<Send size={16} />
|
|
)}
|
|
{t('staff.sendInvitation', 'Send Invitation')}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</Portal>
|
|
)}
|
|
|
|
{/* Edit Staff Modal */}
|
|
{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">
|
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
|
{t('staff.editStaff', 'Edit Staff Member')}
|
|
</h3>
|
|
<button
|
|
onClick={closeEditModal}
|
|
className="text-gray-400 hover:text-gray-500 dark:hover:text-gray-300"
|
|
>
|
|
<X size={24} />
|
|
</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>
|
|
<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>
|
|
<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>
|
|
|
|
{/* 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 */}
|
|
{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', 'Owners have full access to all features and settings.')}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Error Message */}
|
|
{editError && (
|
|
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
|
<p className="text-sm text-red-600 dark:text-red-400">{editError}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Success Message */}
|
|
{editSuccess && (
|
|
<div className="p-3 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg">
|
|
<p className="text-sm text-green-600 dark:text-green-400">{editSuccess}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Danger Zone - Deactivate (only for non-owners, and current user can't deactivate themselves) */}
|
|
{editingStaff.role !== 'owner' && effectiveUser.id !== editingStaff.id && effectiveUser.role === 'owner' && (
|
|
<div className="pt-4 border-t border-gray-200 dark:border-gray-700">
|
|
<h4 className="text-sm font-semibold text-red-600 dark:text-red-400 mb-2">
|
|
{t('staff.dangerZone', 'Danger Zone')}
|
|
</h4>
|
|
<div className="p-3 bg-red-50 dark:bg-red-900/20 rounded-lg border border-red-200 dark:border-red-800">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
|
{editingStaff.is_active
|
|
? t('staff.deactivateAccount', 'Deactivate Account')
|
|
: t('staff.reactivateAccount', 'Reactivate Account')}
|
|
</p>
|
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
|
{editingStaff.is_active
|
|
? t('staff.deactivateHint', 'Prevent this user from logging in while keeping their data')
|
|
: t('staff.reactivateHint', 'Allow this user to log in again')}
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={handleDeactivateFromModal}
|
|
disabled={toggleActiveMutation.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 ${
|
|
editingStaff.is_active
|
|
? 'text-red-600 border border-red-300 hover:bg-red-100 dark:text-red-400 dark:border-red-700 dark:hover:bg-red-900/30'
|
|
: 'text-green-600 border border-green-300 hover:bg-green-100 dark:text-green-400 dark:border-green-700 dark:hover:bg-green-900/30'
|
|
}`}
|
|
>
|
|
{toggleActiveMutation.isPending ? (
|
|
<Loader2 size={14} className="animate-spin" />
|
|
) : (
|
|
<Power size={14} />
|
|
)}
|
|
{editingStaff.is_active
|
|
? t('staff.deactivate', 'Deactivate')
|
|
: t('staff.reactivate', 'Reactivate')}
|
|
</button>
|
|
</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', 'Save Changes')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Portal>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default Staff;
|