feat: add advanced analytics and territory management system
- Add comprehensive analytics components with export functionality - Implement territory management with manager performance tracking - Add seatmap components for venue layout management - Create customer management features with modal interface - Add advanced hooks for dashboard flags and territory data - Implement seat selection and venue management utilities - Add type definitions for ticketing and seatmap systems 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
280
reactrebuild0825/src/queries/events.ts
Normal file
280
reactrebuild0825/src/queries/events.ts
Normal file
@@ -0,0 +1,280 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { useEventStore } from '../stores/eventStore';
|
||||
import { useClaims } from '../hooks/useClaims';
|
||||
import type { Event, EventLite } from '../types/business';
|
||||
|
||||
/**
|
||||
* Utility function to apply territory filtering to a Firebase query
|
||||
* Handles batching for more than 10 territory IDs (Firestore 'in' limit)
|
||||
*/
|
||||
export const applyTerritoryFilter = async <T>(
|
||||
baseQuery: T, // Placeholder for actual query type
|
||||
territoryIds: string[]
|
||||
): Promise<T[]> => {
|
||||
if (territoryIds.length === 0) {
|
||||
return [baseQuery] as T[];
|
||||
}
|
||||
|
||||
// Firestore 'in' operator supports max 10 values, so batch larger queries
|
||||
const chunks: string[][] = [];
|
||||
for (let i = 0; i < territoryIds.length; i += 10) {
|
||||
chunks.push(territoryIds.slice(i, i + 10));
|
||||
}
|
||||
|
||||
// In a real implementation, this would execute multiple Firebase queries
|
||||
// For now, return the base query for each chunk
|
||||
return chunks.map(() => baseQuery);
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to get events with territory filtering applied
|
||||
* Respects user role and territory access controls
|
||||
*/
|
||||
export const useEventsQuery = (selectedTerritoryIds?: string[]) => {
|
||||
const { events, isLoading, error, loadEvents, getFilteredEvents } = useEventStore();
|
||||
const { claims } = useClaims();
|
||||
|
||||
const filteredEvents = useMemo(() => {
|
||||
if (!claims) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let eventsToFilter = getFilteredEvents();
|
||||
|
||||
// Apply territory filtering based on role
|
||||
if (claims.role === 'territoryManager') {
|
||||
// Territory managers can only see events in their assigned territories
|
||||
const userTerritoryIds = claims.territoryIds || [];
|
||||
eventsToFilter = eventsToFilter.filter(event =>
|
||||
userTerritoryIds.includes(event.territoryId || '')
|
||||
);
|
||||
} else if (claims.role === 'orgAdmin' || claims.role === 'superadmin') {
|
||||
// Admins can optionally filter by selected territories
|
||||
if (selectedTerritoryIds && selectedTerritoryIds.length > 0) {
|
||||
eventsToFilter = eventsToFilter.filter(event =>
|
||||
selectedTerritoryIds.includes(event.territoryId || '')
|
||||
);
|
||||
}
|
||||
// If no territories selected, show all events in org
|
||||
}
|
||||
|
||||
return eventsToFilter;
|
||||
}, [events, claims, selectedTerritoryIds, getFilteredEvents]);
|
||||
|
||||
return {
|
||||
events: filteredEvents,
|
||||
isLoading,
|
||||
error,
|
||||
loadEvents,
|
||||
totalCount: filteredEvents.length,
|
||||
isFiltered: (claims?.role === 'territoryManager') ||
|
||||
(selectedTerritoryIds && selectedTerritoryIds.length > 0)
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to get a single event with territory access validation
|
||||
*/
|
||||
export const useEventQuery = (eventId: string) => {
|
||||
const { getEvent, isLoading, error } = useEventStore();
|
||||
const { claims } = useClaims();
|
||||
|
||||
const event = useMemo(() => {
|
||||
const foundEvent = getEvent(eventId);
|
||||
|
||||
if (!foundEvent || !claims) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check territory access for territory managers
|
||||
if (claims.role === 'territoryManager') {
|
||||
const userTerritoryIds = claims.territoryIds || [];
|
||||
if (!userTerritoryIds.includes(foundEvent.territoryId || '')) {
|
||||
return null; // Access denied
|
||||
}
|
||||
}
|
||||
|
||||
return foundEvent;
|
||||
}, [eventId, getEvent, claims]);
|
||||
|
||||
return {
|
||||
event,
|
||||
isLoading,
|
||||
error,
|
||||
hasAccess: event !== null
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook for creating events with automatic territory assignment
|
||||
*/
|
||||
export const useCreateEventMutation = () => {
|
||||
const { createEvent, isLoading, error } = useEventStore();
|
||||
const { claims } = useClaims();
|
||||
|
||||
const mutate = async (eventData: Omit<Event, 'id' | 'createdAt' | 'updatedAt' | 'territoryId'> & { territoryId?: string }) => {
|
||||
if (!claims) {
|
||||
throw new Error('User not authenticated');
|
||||
}
|
||||
|
||||
// For territory managers, auto-assign their first territory
|
||||
let territoryId = eventData.territoryId;
|
||||
if (claims.role === 'territoryManager' && claims.territoryIds && claims.territoryIds.length > 0) {
|
||||
territoryId = claims.territoryIds[0];
|
||||
}
|
||||
|
||||
return createEvent({
|
||||
...eventData,
|
||||
territoryId: territoryId || undefined
|
||||
} as Omit<Event, 'id' | 'createdAt' | 'updatedAt'>);
|
||||
};
|
||||
|
||||
return {
|
||||
mutate,
|
||||
isLoading,
|
||||
error
|
||||
};
|
||||
};
|
||||
|
||||
interface UseEventsOptions {
|
||||
territoryIds?: string[];
|
||||
}
|
||||
|
||||
interface UseEventsResult {
|
||||
events: EventLite[];
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* React Query hook for fetching events with territory filtering
|
||||
* Simulates Firestore querying with 'in' operator limitations (max 10 items per chunk)
|
||||
*/
|
||||
export const useEvents = (
|
||||
orgId: string,
|
||||
options: UseEventsOptions = {}
|
||||
): UseEventsResult => {
|
||||
const { territoryIds } = options;
|
||||
|
||||
const queryKey = ['events', orgId, territoryIds?.sort()];
|
||||
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey,
|
||||
queryFn: async (): Promise<EventLite[]> => {
|
||||
// Simulate API delay
|
||||
await new Promise(resolve => setTimeout(resolve, 800));
|
||||
|
||||
// Mock events data - in production this would come from Firestore
|
||||
const mockEvents: EventLite[] = [
|
||||
{
|
||||
id: 'evt-1',
|
||||
orgId: 'org_001',
|
||||
name: 'Autumn Gala & Silent Auction',
|
||||
startAt: '2024-11-15T19:00:00Z',
|
||||
endAt: '2024-11-15T23:00:00Z',
|
||||
venue: 'Grand Ballroom at The Meridian',
|
||||
territoryId: 'territory_001',
|
||||
status: 'published'
|
||||
},
|
||||
{
|
||||
id: 'evt-2',
|
||||
orgId: 'org_001',
|
||||
name: 'Contemporary Dance Showcase',
|
||||
startAt: '2024-12-03T20:00:00Z',
|
||||
endAt: '2024-12-03T22:30:00Z',
|
||||
venue: 'Studio Theater at Arts Center',
|
||||
territoryId: 'territory_002',
|
||||
status: 'published'
|
||||
},
|
||||
{
|
||||
id: 'evt-3',
|
||||
orgId: 'org_001',
|
||||
name: 'Holiday Wedding Expo',
|
||||
startAt: '2024-12-14T14:00:00Z',
|
||||
endAt: '2024-12-14T18:00:00Z',
|
||||
venue: 'Convention Center Hall A',
|
||||
territoryId: 'territory_003',
|
||||
status: 'draft'
|
||||
},
|
||||
{
|
||||
id: 'evt-4',
|
||||
orgId: 'org_001',
|
||||
name: 'New Year\'s Eve Celebration',
|
||||
startAt: '2024-12-31T21:00:00Z',
|
||||
endAt: '2025-01-01T02:00:00Z',
|
||||
venue: 'Rooftop Terrace',
|
||||
territoryId: 'territory_001',
|
||||
status: 'published'
|
||||
},
|
||||
{
|
||||
id: 'evt-5',
|
||||
orgId: 'org_001',
|
||||
name: 'Spring Concert Series',
|
||||
startAt: '2025-03-20T19:30:00Z',
|
||||
endAt: '2025-03-20T21:30:00Z',
|
||||
venue: 'Outdoor Amphitheater',
|
||||
territoryId: 'territory_002',
|
||||
status: 'draft'
|
||||
},
|
||||
{
|
||||
id: 'evt-6',
|
||||
orgId: 'org_001',
|
||||
name: 'Corporate Awards Gala',
|
||||
startAt: '2025-02-14T18:00:00Z',
|
||||
endAt: '2025-02-14T23:00:00Z',
|
||||
venue: 'Downtown Conference Center',
|
||||
territoryId: 'territory_003',
|
||||
status: 'archived'
|
||||
}
|
||||
];
|
||||
|
||||
// Filter by organization
|
||||
let filteredEvents = mockEvents.filter(event => event.orgId === orgId);
|
||||
|
||||
// Apply territory filtering if provided
|
||||
if (territoryIds && territoryIds.length > 0) {
|
||||
// Simulate Firestore 'in' operator limitations (max 10 items per chunk)
|
||||
const chunks = [];
|
||||
for (let i = 0; i < territoryIds.length; i += 10) {
|
||||
chunks.push(territoryIds.slice(i, i + 10));
|
||||
}
|
||||
|
||||
// In real implementation, this would be multiple Firestore queries
|
||||
const territoryFilteredEvents = chunks.flatMap(chunk =>
|
||||
filteredEvents.filter(event => chunk.includes(event.territoryId))
|
||||
);
|
||||
|
||||
// Remove duplicates (shouldn't happen with proper chunking)
|
||||
const uniqueEvents = Array.from(
|
||||
new Map(territoryFilteredEvents.map(event => [event.id, event])).values()
|
||||
);
|
||||
|
||||
filteredEvents = uniqueEvents;
|
||||
}
|
||||
|
||||
// Sort by startAt ascending, then by name ascending
|
||||
filteredEvents.sort((a, b) => {
|
||||
const dateComparison = new Date(a.startAt).getTime() - new Date(b.startAt).getTime();
|
||||
if (dateComparison !== 0) {
|
||||
return dateComparison;
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
return filteredEvents;
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
gcTime: 10 * 60 * 1000, // 10 minutes
|
||||
enabled: !!orgId
|
||||
});
|
||||
|
||||
return {
|
||||
events: data || [],
|
||||
isLoading,
|
||||
error: error as Error | null,
|
||||
refetch
|
||||
};
|
||||
};
|
||||
130
reactrebuild0825/src/queries/ticketTypes.ts
Normal file
130
reactrebuild0825/src/queries/ticketTypes.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useEventStore } from '../stores/eventStore';
|
||||
import { useClaims } from '../hooks/useClaims';
|
||||
import type { TicketType } from '../types/business';
|
||||
|
||||
/**
|
||||
* Hook to get ticket types with territory filtering applied
|
||||
* Respects user role and territory access controls
|
||||
*/
|
||||
export const useTicketTypesQuery = (eventId?: string, selectedTerritoryIds?: string[]) => {
|
||||
const { ticketTypes, isLoading, error, getTicketTypesForEvent } = useEventStore();
|
||||
const { claims } = useClaims();
|
||||
|
||||
const filteredTicketTypes = useMemo(() => {
|
||||
if (!claims) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let ticketTypesToFilter: TicketType[];
|
||||
|
||||
if (eventId) {
|
||||
// Get ticket types for specific event
|
||||
ticketTypesToFilter = getTicketTypesForEvent(eventId);
|
||||
} else {
|
||||
// Get all ticket types and filter by territory
|
||||
ticketTypesToFilter = ticketTypes;
|
||||
}
|
||||
|
||||
// Apply territory filtering based on role
|
||||
if (claims.role === 'territoryManager') {
|
||||
// Territory managers can only see ticket types for events in their territories
|
||||
const userTerritoryIds = claims.territoryIds || [];
|
||||
ticketTypesToFilter = ticketTypesToFilter.filter(ticketType => {
|
||||
// We'd need to join with events to get territoryId
|
||||
// For now, assume ticket types have a territoryId field or we check the event
|
||||
return userTerritoryIds.includes(ticketType.territoryId || '');
|
||||
});
|
||||
} else if (claims.role === 'orgAdmin' || claims.role === 'superadmin') {
|
||||
// Admins can optionally filter by selected territories
|
||||
if (selectedTerritoryIds && selectedTerritoryIds.length > 0) {
|
||||
ticketTypesToFilter = ticketTypesToFilter.filter(ticketType =>
|
||||
selectedTerritoryIds.includes(ticketType.territoryId || '')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return ticketTypesToFilter;
|
||||
}, [ticketTypes, eventId, claims, selectedTerritoryIds, getTicketTypesForEvent]);
|
||||
|
||||
return {
|
||||
ticketTypes: filteredTicketTypes,
|
||||
isLoading,
|
||||
error,
|
||||
totalCount: filteredTicketTypes.length,
|
||||
isFiltered: (claims?.role === 'territoryManager') ||
|
||||
(selectedTerritoryIds && selectedTerritoryIds.length > 0)
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to get ticket types for a specific event with territory validation
|
||||
*/
|
||||
export const useEventTicketTypesQuery = (eventId: string) => {
|
||||
const { getTicketTypesForEvent, getEvent, isLoading, error } = useEventStore();
|
||||
const { claims } = useClaims();
|
||||
|
||||
const ticketTypes = useMemo(() => {
|
||||
if (!claims) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const event = getEvent(eventId);
|
||||
if (!event) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Check territory access for territory managers
|
||||
if (claims.role === 'territoryManager') {
|
||||
const userTerritoryIds = claims.territoryIds || [];
|
||||
if (!userTerritoryIds.includes(event.territoryId || '')) {
|
||||
return []; // Access denied to event, so no ticket types
|
||||
}
|
||||
}
|
||||
|
||||
return getTicketTypesForEvent(eventId);
|
||||
}, [eventId, getTicketTypesForEvent, getEvent, claims]);
|
||||
|
||||
return {
|
||||
ticketTypes,
|
||||
isLoading,
|
||||
error,
|
||||
hasAccess: ticketTypes.length > 0 || claims?.role !== 'territoryManager'
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook for creating ticket types with automatic territory validation
|
||||
*/
|
||||
export const useCreateTicketTypeMutation = () => {
|
||||
const { createTicketType, getEvent, isLoading, error } = useEventStore();
|
||||
const { claims } = useClaims();
|
||||
|
||||
const mutate = async (ticketTypeData: Omit<TicketType, 'id' | 'createdAt' | 'updatedAt'>) => {
|
||||
if (!claims) {
|
||||
throw new Error('User not authenticated');
|
||||
}
|
||||
|
||||
// Validate territory access for the event
|
||||
const event = getEvent(ticketTypeData.eventId);
|
||||
if (!event) {
|
||||
throw new Error('Event not found');
|
||||
}
|
||||
|
||||
if (claims.role === 'territoryManager') {
|
||||
const userTerritoryIds = claims.territoryIds || [];
|
||||
if (!userTerritoryIds.includes(event.territoryId || '')) {
|
||||
throw new Error('Access denied: Event not in your assigned territories');
|
||||
}
|
||||
}
|
||||
|
||||
return createTicketType(ticketTypeData);
|
||||
};
|
||||
|
||||
return {
|
||||
mutate,
|
||||
isLoading,
|
||||
error
|
||||
};
|
||||
};
|
||||
147
reactrebuild0825/src/queries/tickets.ts
Normal file
147
reactrebuild0825/src/queries/tickets.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useTicketStore } from '../stores/ticketStore';
|
||||
import { useEventStore } from '../stores/eventStore';
|
||||
import { useClaims } from '../hooks/useClaims';
|
||||
import type { Ticket } from '../types/business';
|
||||
|
||||
/**
|
||||
* Hook to get tickets with territory filtering applied
|
||||
* Respects user role and territory access controls
|
||||
*/
|
||||
export const useTicketsQuery = (eventId?: string, selectedTerritoryIds?: string[]) => {
|
||||
const { tickets, isLoading, error } = useTicketStore();
|
||||
const { getEvent } = useEventStore();
|
||||
const { claims } = useClaims();
|
||||
|
||||
const filteredTickets = useMemo(() => {
|
||||
if (!claims) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let ticketsToFilter = tickets;
|
||||
|
||||
// Filter by event if specified
|
||||
if (eventId) {
|
||||
ticketsToFilter = ticketsToFilter.filter(ticket => ticket.eventId === eventId);
|
||||
}
|
||||
|
||||
// Apply territory filtering based on role
|
||||
if (claims.role === 'territoryManager') {
|
||||
// Territory managers can only see tickets for events in their territories
|
||||
const userTerritoryIds = claims.territoryIds || [];
|
||||
ticketsToFilter = ticketsToFilter.filter(ticket => {
|
||||
const event = getEvent(ticket.eventId);
|
||||
return event && userTerritoryIds.includes(event.territoryId || '');
|
||||
});
|
||||
} else if (claims.role === 'orgAdmin' || claims.role === 'superadmin') {
|
||||
// Admins can optionally filter by selected territories
|
||||
if (selectedTerritoryIds && selectedTerritoryIds.length > 0) {
|
||||
ticketsToFilter = ticketsToFilter.filter(ticket => {
|
||||
const event = getEvent(ticket.eventId);
|
||||
return event && selectedTerritoryIds.includes(event.territoryId || '');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return ticketsToFilter;
|
||||
}, [tickets, eventId, claims, selectedTerritoryIds, getEvent]);
|
||||
|
||||
return {
|
||||
tickets: filteredTickets,
|
||||
isLoading,
|
||||
error,
|
||||
totalCount: filteredTickets.length,
|
||||
isFiltered: (claims?.role === 'territoryManager') ||
|
||||
(selectedTerritoryIds && selectedTerritoryIds.length > 0)
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to get tickets for a specific event with territory validation
|
||||
*/
|
||||
export const useEventTicketsQuery = (eventId: string) => {
|
||||
const { tickets, isLoading, error } = useTicketStore();
|
||||
const { getEvent } = useEventStore();
|
||||
const { claims } = useClaims();
|
||||
|
||||
const eventTickets = useMemo(() => {
|
||||
if (!claims) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const event = getEvent(eventId);
|
||||
if (!event) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Check territory access for territory managers
|
||||
if (claims.role === 'territoryManager') {
|
||||
const userTerritoryIds = claims.territoryIds || [];
|
||||
if (!userTerritoryIds.includes(event.territoryId || '')) {
|
||||
return []; // Access denied to event, so no tickets
|
||||
}
|
||||
}
|
||||
|
||||
return tickets.filter(ticket => ticket.eventId === eventId);
|
||||
}, [eventId, tickets, getEvent, claims]);
|
||||
|
||||
return {
|
||||
tickets: eventTickets,
|
||||
isLoading,
|
||||
error,
|
||||
hasAccess: eventTickets.length > 0 || claims?.role !== 'territoryManager'
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to get ticket statistics with territory filtering
|
||||
*/
|
||||
export const useTicketStatsQuery = (selectedTerritoryIds?: string[]) => {
|
||||
const { tickets } = useTicketStore();
|
||||
const { getEvent } = useEventStore();
|
||||
const { claims } = useClaims();
|
||||
|
||||
const stats = useMemo(() => {
|
||||
if (!claims) {
|
||||
return {
|
||||
totalSold: 0,
|
||||
totalRevenue: 0,
|
||||
pendingTickets: 0,
|
||||
scannedTickets: 0
|
||||
};
|
||||
}
|
||||
|
||||
let ticketsToAnalyze = tickets;
|
||||
|
||||
// Apply territory filtering based on role
|
||||
if (claims.role === 'territoryManager') {
|
||||
const userTerritoryIds = claims.territoryIds || [];
|
||||
ticketsToAnalyze = ticketsToAnalyze.filter(ticket => {
|
||||
const event = getEvent(ticket.eventId);
|
||||
return event && userTerritoryIds.includes(event.territoryId || '');
|
||||
});
|
||||
} else if (claims.role === 'orgAdmin' || claims.role === 'superadmin') {
|
||||
if (selectedTerritoryIds && selectedTerritoryIds.length > 0) {
|
||||
ticketsToAnalyze = ticketsToAnalyze.filter(ticket => {
|
||||
const event = getEvent(ticket.eventId);
|
||||
return event && selectedTerritoryIds.includes(event.territoryId || '');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const totalSold = ticketsToAnalyze.length;
|
||||
const totalRevenue = ticketsToAnalyze.reduce((sum, ticket) => sum + ticket.price, 0);
|
||||
const pendingTickets = ticketsToAnalyze.filter(ticket => ticket.status === 'pending').length;
|
||||
const scannedTickets = ticketsToAnalyze.filter(ticket => ticket.status === 'scanned').length;
|
||||
|
||||
return {
|
||||
totalSold,
|
||||
totalRevenue,
|
||||
pendingTickets,
|
||||
scannedTickets
|
||||
};
|
||||
}, [tickets, claims, selectedTerritoryIds, getEvent]);
|
||||
|
||||
return stats;
|
||||
};
|
||||
Reference in New Issue
Block a user