feat: Implement staff invitation system with role-based permissions
- Add StaffInvitation model with token-based 7-day expiration
- Create invitation API endpoints (create, cancel, resend, accept, decline)
- Add permissions JSONField to User model for granular access control
- Implement frontend invite modal with role-specific permissions:
- Manager: can_invite_staff, can_manage_resources, can_manage_services,
can_view_reports, can_access_settings, can_refund_payments
- Staff: can_view_all_schedules, can_manage_own_appointments
- Add edit staff modal with permissions management and deactivate option
- Create AcceptInvitePage for invitation acceptance flow
- Add active/inactive staff separation with collapsible section
- Auto-create bookable resource when configured at invite time
- Remove Quick Add Appointment from dashboard
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -53,6 +53,7 @@ import PlatformSettings from './pages/platform/PlatformSettings';
|
||||
import ProfileSettings from './pages/ProfileSettings';
|
||||
import VerifyEmail from './pages/VerifyEmail';
|
||||
import EmailVerificationRequired from './pages/EmailVerificationRequired';
|
||||
import AcceptInvitePage from './pages/AcceptInvitePage';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@@ -210,6 +211,7 @@ const AppContent: React.FC = () => {
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/oauth/callback/:provider" element={<OAuthCallback />} />
|
||||
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||
<Route path="/accept-invite" element={<AcceptInvitePage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
@@ -222,6 +224,7 @@ const AppContent: React.FC = () => {
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/oauth/callback/:provider" element={<OAuthCallback />} />
|
||||
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||
<Route path="/accept-invite" element={<AcceptInvitePage />} />
|
||||
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
|
||||
@@ -15,6 +15,20 @@ import {
|
||||
} from '../api/auth';
|
||||
import { getCookie, setCookie, deleteCookie } from '../utils/cookies';
|
||||
|
||||
/**
|
||||
* Helper hook to set auth tokens (used by invitation acceptance)
|
||||
*/
|
||||
export const useAuth = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const setTokens = (accessToken: string, refreshToken: string) => {
|
||||
setCookie('access_token', accessToken, 7);
|
||||
setCookie('refresh_token', refreshToken, 7);
|
||||
};
|
||||
|
||||
return { setTokens };
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to get current user
|
||||
*/
|
||||
|
||||
165
frontend/src/hooks/useInvitations.ts
Normal file
165
frontend/src/hooks/useInvitations.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Staff Invitations Management Hooks
|
||||
*/
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import apiClient from '../api/client';
|
||||
|
||||
export interface StaffInvitation {
|
||||
id: number;
|
||||
email: string;
|
||||
role: 'TENANT_MANAGER' | 'TENANT_STAFF';
|
||||
role_display: string;
|
||||
status: 'PENDING' | 'ACCEPTED' | 'DECLINED' | 'EXPIRED' | 'CANCELLED';
|
||||
invited_by: number | null;
|
||||
invited_by_name: string | null;
|
||||
created_at: string;
|
||||
expires_at: string;
|
||||
accepted_at: string | null;
|
||||
create_bookable_resource: boolean;
|
||||
resource_name: string;
|
||||
permissions: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export interface InvitationDetails {
|
||||
email: string;
|
||||
role: string;
|
||||
role_display: string;
|
||||
business_name: string;
|
||||
invited_by: string | null;
|
||||
expires_at: string;
|
||||
create_bookable_resource: boolean;
|
||||
resource_name: string;
|
||||
}
|
||||
|
||||
export interface StaffPermissions {
|
||||
// Manager permissions
|
||||
can_invite_staff?: boolean;
|
||||
can_manage_resources?: boolean;
|
||||
can_manage_services?: boolean;
|
||||
can_view_reports?: boolean;
|
||||
can_access_settings?: boolean;
|
||||
can_refund_payments?: boolean;
|
||||
// Staff permissions
|
||||
can_view_all_schedules?: boolean;
|
||||
can_manage_own_appointments?: boolean;
|
||||
}
|
||||
|
||||
export interface CreateInvitationData {
|
||||
email: string;
|
||||
role: 'TENANT_MANAGER' | 'TENANT_STAFF';
|
||||
create_bookable_resource?: boolean;
|
||||
resource_name?: string;
|
||||
permissions?: StaffPermissions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch pending invitations for the current business
|
||||
*/
|
||||
export const useInvitations = () => {
|
||||
return useQuery<StaffInvitation[]>({
|
||||
queryKey: ['invitations'],
|
||||
queryFn: async () => {
|
||||
const { data } = await apiClient.get('/api/staff/invitations/');
|
||||
return data;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to create a new staff invitation
|
||||
*/
|
||||
export const useCreateInvitation = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (invitationData: CreateInvitationData) => {
|
||||
const { data } = await apiClient.post('/api/staff/invitations/', invitationData);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['invitations'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to cancel a pending invitation
|
||||
*/
|
||||
export const useCancelInvitation = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (invitationId: number) => {
|
||||
await apiClient.delete(`/api/staff/invitations/${invitationId}/`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['invitations'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to resend an invitation email
|
||||
*/
|
||||
export const useResendInvitation = () => {
|
||||
return useMutation({
|
||||
mutationFn: async (invitationId: number) => {
|
||||
const { data } = await apiClient.post(`/api/staff/invitations/${invitationId}/resend/`);
|
||||
return data;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to get invitation details by token (for acceptance page)
|
||||
*/
|
||||
export const useInvitationDetails = (token: string | null) => {
|
||||
return useQuery<InvitationDetails>({
|
||||
queryKey: ['invitation', token],
|
||||
queryFn: async () => {
|
||||
const { data } = await apiClient.get(`/api/staff/invitations/token/${token}/`);
|
||||
return data;
|
||||
},
|
||||
enabled: !!token,
|
||||
retry: false,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to accept an invitation
|
||||
*/
|
||||
export const useAcceptInvitation = () => {
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
token,
|
||||
firstName,
|
||||
lastName,
|
||||
password,
|
||||
}: {
|
||||
token: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
password: string;
|
||||
}) => {
|
||||
const { data } = await apiClient.post(`/api/staff/invitations/token/${token}/accept/`, {
|
||||
first_name: firstName,
|
||||
last_name: lastName,
|
||||
password,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to decline an invitation
|
||||
*/
|
||||
export const useDeclineInvitation = () => {
|
||||
return useMutation({
|
||||
mutationFn: async (token: string) => {
|
||||
const { data } = await apiClient.post(`/api/staff/invitations/token/${token}/decline/`);
|
||||
return data;
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -2,14 +2,22 @@
|
||||
* Staff Management Hooks
|
||||
*/
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import apiClient from '../api/client';
|
||||
|
||||
export interface StaffPermissions {
|
||||
can_invite_staff?: boolean;
|
||||
}
|
||||
|
||||
export interface StaffMember {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
phone?: string;
|
||||
role: string;
|
||||
is_active: boolean;
|
||||
permissions: StaffPermissions;
|
||||
can_invite_staff: boolean;
|
||||
}
|
||||
|
||||
interface StaffFilters {
|
||||
@@ -26,6 +34,7 @@ export const useStaff = (filters?: StaffFilters) => {
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.search) params.append('search', filters.search);
|
||||
params.append('show_inactive', 'true'); // Always fetch inactive staff too
|
||||
|
||||
const { data } = await apiClient.get(`/api/staff/?${params}`);
|
||||
|
||||
@@ -35,8 +44,54 @@ export const useStaff = (filters?: StaffFilters) => {
|
||||
name: s.name || `${s.first_name || ''} ${s.last_name || ''}`.trim() || s.email,
|
||||
email: s.email || '',
|
||||
phone: s.phone || '',
|
||||
role: s.role || 'staff',
|
||||
is_active: s.is_active ?? true,
|
||||
permissions: s.permissions || {},
|
||||
can_invite_staff: s.can_invite_staff ?? false,
|
||||
}));
|
||||
},
|
||||
retry: false,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to update a staff member's settings
|
||||
*/
|
||||
export const useUpdateStaff = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
id,
|
||||
updates,
|
||||
}: {
|
||||
id: string;
|
||||
updates: { is_active?: boolean; permissions?: StaffPermissions };
|
||||
}) => {
|
||||
const { data } = await apiClient.patch(`/api/staff/${id}/`, updates);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['staff'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['businessUsers'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to toggle a staff member's active status
|
||||
*/
|
||||
export const useToggleStaffActive = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
const { data } = await apiClient.post(`/api/staff/${id}/toggle_active/`);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['staff'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['businessUsers'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
352
frontend/src/pages/AcceptInvitePage.tsx
Normal file
352
frontend/src/pages/AcceptInvitePage.tsx
Normal file
@@ -0,0 +1,352 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
useInvitationDetails,
|
||||
useAcceptInvitation,
|
||||
useDeclineInvitation,
|
||||
} from '../hooks/useInvitations';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import {
|
||||
Loader2,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Building2,
|
||||
Mail,
|
||||
User,
|
||||
Lock,
|
||||
AlertCircle,
|
||||
Eye,
|
||||
EyeOff,
|
||||
} from 'lucide-react';
|
||||
|
||||
const AcceptInvitePage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const token = searchParams.get('token');
|
||||
|
||||
const { data: invitation, isLoading, error } = useInvitationDetails(token);
|
||||
const acceptInvitationMutation = useAcceptInvitation();
|
||||
const declineInvitationMutation = useDeclineInvitation();
|
||||
const { setTokens } = useAuth();
|
||||
|
||||
const [firstName, setFirstName] = useState('');
|
||||
const [lastName, setLastName] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [formError, setFormError] = useState('');
|
||||
const [accepted, setAccepted] = useState(false);
|
||||
const [declined, setDeclined] = useState(false);
|
||||
|
||||
const handleAccept = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setFormError('');
|
||||
|
||||
// Validate
|
||||
if (!firstName.trim()) {
|
||||
setFormError(t('acceptInvite.firstNameRequired', 'First name is required'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!password || password.length < 8) {
|
||||
setFormError(t('acceptInvite.passwordMinLength', 'Password must be at least 8 characters'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setFormError(t('acceptInvite.passwordsMustMatch', 'Passwords do not match'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await acceptInvitationMutation.mutateAsync({
|
||||
token: token!,
|
||||
firstName: firstName.trim(),
|
||||
lastName: lastName.trim(),
|
||||
password,
|
||||
});
|
||||
|
||||
// Set auth tokens and redirect to dashboard
|
||||
setTokens(result.access, result.refresh);
|
||||
setAccepted(true);
|
||||
|
||||
// Redirect after a short delay
|
||||
setTimeout(() => {
|
||||
navigate('/');
|
||||
}, 2000);
|
||||
} catch (err: any) {
|
||||
setFormError(err.response?.data?.error || t('acceptInvite.acceptFailed', 'Failed to accept invitation'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDecline = async () => {
|
||||
if (!confirm(t('acceptInvite.confirmDecline', 'Are you sure you want to decline this invitation?'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await declineInvitationMutation.mutateAsync(token!);
|
||||
setDeclined(true);
|
||||
} catch (err: any) {
|
||||
setFormError(err.response?.data?.error || t('acceptInvite.declineFailed', 'Failed to decline invitation'));
|
||||
}
|
||||
};
|
||||
|
||||
// No token provided
|
||||
if (!token) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 p-4">
|
||||
<div className="max-w-md w-full bg-white dark:bg-gray-800 rounded-xl shadow-lg p-8 text-center">
|
||||
<XCircle size={48} className="mx-auto text-red-500 mb-4" />
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
{t('acceptInvite.invalidLink', 'Invalid Invitation Link')}
|
||||
</h1>
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
{t('acceptInvite.noToken', 'This invitation link is invalid. Please check your email for the correct link.')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
|
||||
<div className="text-center">
|
||||
<Loader2 size={48} className="mx-auto text-brand-600 animate-spin mb-4" />
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
{t('acceptInvite.loading', 'Loading invitation...')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state (invalid/expired token)
|
||||
if (error || !invitation) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 p-4">
|
||||
<div className="max-w-md w-full bg-white dark:bg-gray-800 rounded-xl shadow-lg p-8 text-center">
|
||||
<AlertCircle size={48} className="mx-auto text-amber-500 mb-4" />
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
{t('acceptInvite.expiredTitle', 'Invitation Expired or Invalid')}
|
||||
</h1>
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
{t(
|
||||
'acceptInvite.expiredDescription',
|
||||
'This invitation has expired or is no longer valid. Please contact the person who sent the invitation to request a new one.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Accepted state
|
||||
if (accepted) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 p-4">
|
||||
<div className="max-w-md w-full bg-white dark:bg-gray-800 rounded-xl shadow-lg p-8 text-center">
|
||||
<CheckCircle size={48} className="mx-auto text-green-500 mb-4" />
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
{t('acceptInvite.welcomeTitle', 'Welcome to the Team!')}
|
||||
</h1>
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
{t('acceptInvite.redirecting', 'Your account has been created. Redirecting to dashboard...')}
|
||||
</p>
|
||||
<Loader2 size={24} className="mx-auto text-brand-600 animate-spin mt-4" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Declined state
|
||||
if (declined) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 p-4">
|
||||
<div className="max-w-md w-full bg-white dark:bg-gray-800 rounded-xl shadow-lg p-8 text-center">
|
||||
<XCircle size={48} className="mx-auto text-gray-400 mb-4" />
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
{t('acceptInvite.declinedTitle', 'Invitation Declined')}
|
||||
</h1>
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
{t('acceptInvite.declinedDescription', "You've declined this invitation. You can close this page.")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Main acceptance form
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 p-4">
|
||||
<div className="max-w-md w-full bg-white dark:bg-gray-800 rounded-xl shadow-lg overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="bg-brand-600 p-6 text-center">
|
||||
<h1 className="text-2xl font-bold text-white mb-2">
|
||||
{t('acceptInvite.title', "You're Invited!")}
|
||||
</h1>
|
||||
<p className="text-brand-100">
|
||||
{t('acceptInvite.subtitle', 'Join the team and start scheduling')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Invitation Details */}
|
||||
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-brand-100 dark:bg-brand-900/50 flex items-center justify-center text-brand-600 dark:text-brand-400">
|
||||
<Building2 size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">
|
||||
{t('acceptInvite.business', 'Business')}
|
||||
</p>
|
||||
<p className="font-semibold text-gray-900 dark:text-white">{invitation.business_name}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-gray-100 dark:bg-gray-700 flex items-center justify-center text-gray-600 dark:text-gray-400">
|
||||
<Mail size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">
|
||||
{t('acceptInvite.invitedAs', 'Invited As')}
|
||||
</p>
|
||||
<p className="font-semibold text-gray-900 dark:text-white">
|
||||
{invitation.role_display} •{' '}
|
||||
<span className="font-normal text-gray-600 dark:text-gray-400">{invitation.email}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{invitation.invited_by && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 text-center pt-2">
|
||||
{t('acceptInvite.invitedBy', 'Invited by')} <span className="font-medium">{invitation.invited_by}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleAccept} className="p-6 space-y-4">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
{t('acceptInvite.formDescription', 'Create your account to accept this invitation.')}
|
||||
</p>
|
||||
|
||||
<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('acceptInvite.firstName', 'First Name')} *
|
||||
</label>
|
||||
<div className="relative">
|
||||
<User size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={firstName}
|
||||
onChange={(e) => setFirstName(e.target.value)}
|
||||
required
|
||||
className="w-full pl-10 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="John"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('acceptInvite.lastName', 'Last Name')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={lastName}
|
||||
onChange={(e) => setLastName(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="Doe"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('acceptInvite.password', 'Password')} *
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
className="w-full pl-10 pr-10 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="Min. 8 characters"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('acceptInvite.confirmPassword', 'Confirm Password')} *
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
className="w-full pl-10 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="Repeat password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{formError && (
|
||||
<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">{formError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex flex-col gap-3 pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={acceptInvitationMutation.isPending}
|
||||
className="w-full px-4 py-3 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 justify-center gap-2"
|
||||
>
|
||||
{acceptInvitationMutation.isPending ? (
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
) : (
|
||||
<CheckCircle size={18} />
|
||||
)}
|
||||
{t('acceptInvite.acceptButton', 'Accept Invitation & Create Account')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDecline}
|
||||
disabled={declineInvitationMutation.isPending}
|
||||
className="w-full px-4 py-2 text-sm font-medium text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 transition-colors"
|
||||
>
|
||||
{t('acceptInvite.declineButton', 'Decline Invitation')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AcceptInvitePage;
|
||||
@@ -16,7 +16,6 @@ import { useServices } from '../hooks/useServices';
|
||||
import { useResources } from '../hooks/useResources';
|
||||
import { useAppointments } from '../hooks/useAppointments';
|
||||
import { useCustomers } from '../hooks/useCustomers';
|
||||
import QuickAddAppointment from '../components/QuickAddAppointment';
|
||||
|
||||
interface Metric {
|
||||
label: string;
|
||||
@@ -147,14 +146,9 @@ const Dashboard: React.FC = () => {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{/* Quick Add Appointment */}
|
||||
<div className="lg:col-span-1">
|
||||
<QuickAddAppointment />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6">
|
||||
{/* Revenue Chart */}
|
||||
<div className="lg:col-span-2 p-6 bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700 rounded-xl shadow-sm transition-colors duration-200">
|
||||
<div className="p-6 bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700 rounded-xl shadow-sm transition-colors duration-200">
|
||||
<h3 className="mb-6 text-lg font-semibold text-gray-900 dark:text-white">{t('dashboard.totalRevenue')}</h3>
|
||||
<div className="h-80">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user