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:
poduck
2025-11-28 01:11:53 -05:00
parent a7c756a8ec
commit b10426fbdb
52 changed files with 4259 additions and 356 deletions

View File

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

View File

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

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

View File

@@ -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;

View File

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

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