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:
2025-08-26 09:25:10 -06:00
parent d5c3953888
commit aa81eb5adb
438 changed files with 90509 additions and 2787 deletions

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

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

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