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:
poduck
2025-11-28 02:03:48 -05:00
parent b10426fbdb
commit 83815fcb34
15 changed files with 2477 additions and 181 deletions

View File

@@ -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
*/

View 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;
},
});
};

View File

@@ -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'] });
},
});
};