feat: Add photo galleries to services, resource types management, and UI improvements
Major features: - Add drag-and-drop photo gallery to Service create/edit modals - Add Resource Types management section to Settings (CRUD for custom types) - Add edit icon consistency to Resources table (pencil icon in actions) - Improve Services page with drag-to-reorder and customer preview mockup Backend changes: - Add photos JSONField to Service model with migration - Add ResourceType model with category (STAFF/OTHER), description fields - Add ResourceTypeViewSet with CRUD operations - Add service reorder endpoint for display order Frontend changes: - Services page: two-column layout, drag-reorder, photo upload - Settings page: Resource Types tab with full CRUD modal - Resources page: Edit icon in actions column instead of row click - Sidebar: Payments link visibility based on role and paymentsEnabled - Update types.ts with Service.photos and ResourceTypeDefinition Note: Removed photos from ResourceType (kept only for Service) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -101,13 +101,13 @@ export const useMasquerade = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (username: string) => {
|
||||
mutationFn: async (user_pk: number) => {
|
||||
// Get current masquerading stack from localStorage
|
||||
const stackJson = localStorage.getItem('masquerade_stack');
|
||||
const currentStack: MasqueradeStackEntry[] = stackJson ? JSON.parse(stackJson) : [];
|
||||
|
||||
// Call masquerade API with current stack
|
||||
return masquerade(username, currentStack);
|
||||
return masquerade(user_pk, currentStack);
|
||||
},
|
||||
onSuccess: async (data) => {
|
||||
// Store the updated masquerading stack
|
||||
|
||||
@@ -33,6 +33,8 @@ export const useCurrentBusiness = () => {
|
||||
primaryColor: data.primary_color || '#3B82F6', // Blue-500 default
|
||||
secondaryColor: data.secondary_color || '#1E40AF', // Blue-800 default
|
||||
logoUrl: data.logo_url,
|
||||
emailLogoUrl: data.email_logo_url,
|
||||
logoDisplayMode: data.logo_display_mode || 'text-only',
|
||||
whitelabelEnabled: data.whitelabel_enabled,
|
||||
plan: data.tier, // Map tier to plan
|
||||
status: data.status,
|
||||
@@ -64,6 +66,8 @@ export const useUpdateBusiness = () => {
|
||||
if (updates.primaryColor) backendData.primary_color = updates.primaryColor;
|
||||
if (updates.secondaryColor) backendData.secondary_color = updates.secondaryColor;
|
||||
if (updates.logoUrl !== undefined) backendData.logo_url = updates.logoUrl;
|
||||
if (updates.emailLogoUrl !== undefined) backendData.email_logo_url = updates.emailLogoUrl;
|
||||
if (updates.logoDisplayMode !== undefined) backendData.logo_display_mode = updates.logoDisplayMode;
|
||||
if (updates.whitelabelEnabled !== undefined) {
|
||||
backendData.whitelabel_enabled = updates.whitelabelEnabled;
|
||||
}
|
||||
@@ -136,7 +140,7 @@ export const useBusinessUsers = () => {
|
||||
return useQuery({
|
||||
queryKey: ['businessUsers'],
|
||||
queryFn: async () => {
|
||||
const { data } = await apiClient.get('/api/business/users/');
|
||||
const { data } = await apiClient.get('/api/staff/');
|
||||
return data;
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
|
||||
91
frontend/src/hooks/useResourceTypes.ts
Normal file
91
frontend/src/hooks/useResourceTypes.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Resource Types Management Hooks
|
||||
*/
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import apiClient from '../api/client';
|
||||
import { ResourceTypeDefinition } from '../types';
|
||||
|
||||
/**
|
||||
* Hook to fetch resource types for the current business
|
||||
*/
|
||||
export const useResourceTypes = () => {
|
||||
return useQuery<ResourceTypeDefinition[]>({
|
||||
queryKey: ['resourceTypes'],
|
||||
queryFn: async () => {
|
||||
const { data } = await apiClient.get('/api/resource-types/');
|
||||
return data;
|
||||
},
|
||||
// Provide default types if API doesn't have them yet
|
||||
placeholderData: [
|
||||
{
|
||||
id: 'default-staff',
|
||||
name: 'Staff',
|
||||
category: 'STAFF',
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
id: 'default-room',
|
||||
name: 'Room',
|
||||
category: 'OTHER',
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
id: 'default-equipment',
|
||||
name: 'Equipment',
|
||||
category: 'OTHER',
|
||||
isDefault: true,
|
||||
},
|
||||
] as ResourceTypeDefinition[],
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to create a new resource type
|
||||
*/
|
||||
export const useCreateResourceType = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (newType: Omit<ResourceTypeDefinition, 'id' | 'isDefault'>) => {
|
||||
const { data } = await apiClient.post('/api/resource-types/', newType);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['resourceTypes'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to update a resource type
|
||||
*/
|
||||
export const useUpdateResourceType = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ id, updates }: { id: string; updates: Partial<ResourceTypeDefinition> }) => {
|
||||
const { data } = await apiClient.patch(`/api/resource-types/${id}/`, updates);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['resourceTypes'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to delete a resource type
|
||||
*/
|
||||
export const useDeleteResourceType = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
await apiClient.delete(`/api/resource-types/${id}/`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['resourceTypes'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -28,6 +28,8 @@ export const useResources = (filters?: ResourceFilters) => {
|
||||
name: r.name,
|
||||
type: r.type as ResourceType,
|
||||
userId: r.user_id ? String(r.user_id) : undefined,
|
||||
maxConcurrentEvents: r.max_concurrent_events ?? 1,
|
||||
savedLaneCount: r.saved_lane_count,
|
||||
}));
|
||||
},
|
||||
});
|
||||
@@ -47,6 +49,8 @@ export const useResource = (id: string) => {
|
||||
name: data.name,
|
||||
type: data.type as ResourceType,
|
||||
userId: data.user_id ? String(data.user_id) : undefined,
|
||||
maxConcurrentEvents: data.max_concurrent_events ?? 1,
|
||||
savedLaneCount: data.saved_lane_count,
|
||||
};
|
||||
},
|
||||
enabled: !!id,
|
||||
@@ -91,6 +95,12 @@ export const useUpdateResource = () => {
|
||||
if (updates.userId !== undefined) {
|
||||
backendData.user = updates.userId ? parseInt(updates.userId) : null;
|
||||
}
|
||||
if (updates.maxConcurrentEvents !== undefined) {
|
||||
backendData.max_concurrent_events = updates.maxConcurrentEvents;
|
||||
}
|
||||
if (updates.savedLaneCount !== undefined) {
|
||||
backendData.saved_lane_count = updates.savedLaneCount;
|
||||
}
|
||||
|
||||
const { data } = await apiClient.patch(`/api/resources/${id}/`, backendData);
|
||||
return data;
|
||||
|
||||
@@ -22,6 +22,8 @@ export const useServices = () => {
|
||||
durationMinutes: s.duration || s.duration_minutes,
|
||||
price: parseFloat(s.price),
|
||||
description: s.description || '',
|
||||
displayOrder: s.display_order ?? 0,
|
||||
photos: s.photos || [],
|
||||
}));
|
||||
},
|
||||
retry: false, // Don't retry on 404 - endpoint may not exist yet
|
||||
@@ -43,6 +45,8 @@ export const useService = (id: string) => {
|
||||
durationMinutes: data.duration || data.duration_minutes,
|
||||
price: parseFloat(data.price),
|
||||
description: data.description || '',
|
||||
displayOrder: data.display_order ?? 0,
|
||||
photos: data.photos || [],
|
||||
};
|
||||
},
|
||||
enabled: !!id,
|
||||
@@ -63,6 +67,7 @@ export const useCreateService = () => {
|
||||
duration: serviceData.durationMinutes,
|
||||
price: serviceData.price.toString(),
|
||||
description: serviceData.description,
|
||||
photos: serviceData.photos || [],
|
||||
};
|
||||
|
||||
const { data } = await apiClient.post('/api/services/', backendData);
|
||||
@@ -87,6 +92,7 @@ export const useUpdateService = () => {
|
||||
if (updates.durationMinutes) backendData.duration = updates.durationMinutes;
|
||||
if (updates.price) backendData.price = updates.price.toString();
|
||||
if (updates.description !== undefined) backendData.description = updates.description;
|
||||
if (updates.photos !== undefined) backendData.photos = updates.photos;
|
||||
|
||||
const { data } = await apiClient.patch(`/api/services/${id}/`, backendData);
|
||||
return data;
|
||||
@@ -112,3 +118,22 @@ export const useDeleteService = () => {
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to reorder services (drag and drop)
|
||||
*/
|
||||
export const useReorderServices = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (orderedIds: string[]) => {
|
||||
// Convert string IDs to numbers for the backend
|
||||
const order = orderedIds.map(id => parseInt(id, 10));
|
||||
const { data } = await apiClient.post('/api/services/reorder/', { order });
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['services'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
42
frontend/src/hooks/useStaff.ts
Normal file
42
frontend/src/hooks/useStaff.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Staff Management Hooks
|
||||
*/
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import apiClient from '../api/client';
|
||||
|
||||
export interface StaffMember {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
interface StaffFilters {
|
||||
search?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch staff members with optional filters
|
||||
* Staff members are Users with roles: TENANT_OWNER, TENANT_MANAGER, TENANT_STAFF
|
||||
*/
|
||||
export const useStaff = (filters?: StaffFilters) => {
|
||||
return useQuery<StaffMember[]>({
|
||||
queryKey: ['staff', filters],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.search) params.append('search', filters.search);
|
||||
|
||||
const { data } = await apiClient.get(`/api/staff/?${params}`);
|
||||
|
||||
// Transform backend format to frontend format
|
||||
return data.map((s: any) => ({
|
||||
id: String(s.id),
|
||||
name: s.name || `${s.first_name || ''} ${s.last_name || ''}`.trim() || s.email,
|
||||
email: s.email || '',
|
||||
phone: s.phone || '',
|
||||
}));
|
||||
},
|
||||
retry: false,
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user