Files
smoothschedule/frontend/src/pages/Staff.tsx
poduck a9719a5fd2 feat: Add comprehensive sandbox mode, public API system, and platform support
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>
2025-11-28 16:44:06 -05:00

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} &bull; {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;