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:
@@ -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'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user