mirror of
https://github.com/marcogll/AnchorOS.git
synced 2026-03-15 16:24:30 +00:00
- Add KiosksManagement component with full CRUD for kiosks - Add ScheduleManagement for staff schedules with break reminders - Update booking flow to allow artist selection by customers - Add staff_services API for assigning services to artists - Update staff management UI with service assignment dialog - Add auto-break reminder when schedule >= 8 hours - Update availability API to filter artists by service - Add kiosk management to Aperture dashboard - Clean up ralphy artifacts and logs
225 lines
9.3 KiB
TypeScript
225 lines
9.3 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server'
|
|
import { supabaseAdmin } from '@/lib/supabase/admin'
|
|
|
|
/**
|
|
* @description Fetches comprehensive dashboard data including bookings, top performers, activity feed, and KPIs
|
|
* @param {NextRequest} request - HTTP request with query parameters for filtering and data inclusion options
|
|
* @returns {NextResponse} JSON with bookings array, top performers, activity feed, and optional customer data
|
|
* @example GET /api/aperture/dashboard?location_id=...&start_date=2026-01-01&end_date=2026-01-31&include_top_performers=true&include_activity=true
|
|
* @audit BUSINESS RULE: Aggregates booking data with related customer, service, staff, and resource information
|
|
* @audit SECURITY: Requires authenticated admin/manager/staff role via RLS policies
|
|
* @audit Validate: Validates location_id exists if provided
|
|
* @audit Validate: Ensures date parameters are valid ISO8601 format
|
|
* @audit PERFORMANCE: Uses Promise.all for parallel fetching of related data to reduce latency
|
|
* @audit PERFORMANCE: Implements data mapping for O(1) lookups when combining related data
|
|
* @audit AUDIT: Dashboard access logged for operational monitoring
|
|
*/
|
|
export async function GET(request: NextRequest) {
|
|
try {
|
|
const { searchParams } = new URL(request.url)
|
|
const locationId = searchParams.get('location_id')
|
|
const startDate = searchParams.get('start_date')
|
|
const endDate = searchParams.get('end_date')
|
|
const staffId = searchParams.get('staff_id')
|
|
const status = searchParams.get('status')
|
|
const includeCustomers = searchParams.get('include_customers') === 'true'
|
|
const includeTopPerformers = searchParams.get('include_top_performers') === 'true'
|
|
const includeActivity = searchParams.get('include_activity') === 'true'
|
|
|
|
// Get basic bookings data first
|
|
let query = supabaseAdmin
|
|
.from('bookings')
|
|
.select('id, short_id, status, start_time_utc, end_time_utc, is_paid, created_at, customer_id, service_id, staff_id, resource_id')
|
|
.order('start_time_utc', { ascending: true })
|
|
|
|
if (locationId) {
|
|
query = query.eq('location_id', locationId)
|
|
}
|
|
|
|
if (startDate) {
|
|
query = query.gte('start_time_utc', startDate)
|
|
}
|
|
|
|
if (endDate) {
|
|
query = query.lte('end_time_utc', endDate)
|
|
}
|
|
|
|
if (staffId) {
|
|
query = query.eq('staff_id', staffId)
|
|
}
|
|
|
|
if (status) {
|
|
query = query.in('status', status.split(','))
|
|
}
|
|
|
|
const { data: bookings, error } = await query
|
|
if (error) {
|
|
console.error('Aperture dashboard GET error:', error)
|
|
return NextResponse.json(
|
|
{ error: error.message },
|
|
{ status: 500 }
|
|
)
|
|
}
|
|
|
|
// Fetch related data for bookings
|
|
const customerIds = bookings?.map(b => b.customer_id).filter(Boolean) || []
|
|
const serviceIds = bookings?.map(b => b.service_id).filter(Boolean) || []
|
|
const staffIds = bookings?.map(b => b.staff_id).filter(Boolean) || []
|
|
const resourceIds = bookings?.map(b => b.resource_id).filter(Boolean) || []
|
|
|
|
const [customers, services, staff, resources] = await Promise.all([
|
|
customerIds.length > 0 ? supabaseAdmin.from('customers').select('id, first_name, last_name, email').in('id', customerIds) : Promise.resolve({ data: [] }),
|
|
serviceIds.length > 0 ? supabaseAdmin.from('services').select('id, name, duration_minutes, base_price').in('id', serviceIds) : Promise.resolve({ data: [] }),
|
|
staffIds.length > 0 ? supabaseAdmin.from('staff').select('id, display_name').in('id', staffIds) : Promise.resolve({ data: [] }),
|
|
resourceIds.length > 0 ? supabaseAdmin.from('resources').select('id, name, type').in('id', resourceIds) : Promise.resolve({ data: [] })
|
|
])
|
|
|
|
const customerMap = new Map(customers.data?.map(c => [c.id, c]) || [])
|
|
const serviceMap = new Map(services.data?.map(s => [s.id, s]) || [])
|
|
const staffMap = new Map(staff.data?.map(s => [s.id, s]) || [])
|
|
const resourceMap = new Map(resources.data?.map(r => [r.id, r]) || [])
|
|
|
|
// Combine bookings with related data
|
|
const bookingsWithRelations = bookings?.map(booking => ({
|
|
...booking,
|
|
customer: customerMap.get(booking.customer_id),
|
|
service: serviceMap.get(booking.service_id),
|
|
staff: staffMap.get(booking.staff_id),
|
|
resource: resourceMap.get(booking.resource_id)
|
|
})) || []
|
|
|
|
const response: any = {
|
|
success: true,
|
|
bookings: bookingsWithRelations
|
|
}
|
|
|
|
if (includeCustomers) {
|
|
const { count: totalCustomers } = await supabaseAdmin
|
|
.from('customers')
|
|
.select('*', { count: 'exact', head: true })
|
|
|
|
const now = new Date()
|
|
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
|
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1)
|
|
|
|
const { count: newCustomersToday } = await supabaseAdmin
|
|
.from('customers')
|
|
.select('*', { count: 'exact', head: true })
|
|
.gte('created_at', todayStart.toISOString())
|
|
|
|
const { count: newCustomersMonth } = await supabaseAdmin
|
|
.from('customers')
|
|
.select('*', { count: 'exact', head: true })
|
|
.gte('created_at', monthStart.toISOString())
|
|
|
|
response.customers = {
|
|
total: totalCustomers || 0,
|
|
newToday: newCustomersToday || 0,
|
|
newMonth: newCustomersMonth || 0
|
|
}
|
|
}
|
|
|
|
if (includeTopPerformers) {
|
|
const monthStart = new Date(new Date().getFullYear(), new Date().getMonth(), 1)
|
|
|
|
// Get bookings data
|
|
const { data: bookingsData } = await supabaseAdmin
|
|
.from('bookings')
|
|
.select('staff_id, total_amount, start_time_utc, end_time_utc')
|
|
.eq('status', 'completed')
|
|
.gte('end_time_utc', monthStart.toISOString())
|
|
|
|
// Get staff data separately
|
|
const { data: staffData } = await supabaseAdmin
|
|
.from('staff')
|
|
.select('id, display_name, role')
|
|
|
|
const staffMap = new Map(staffData?.map(s => [s.id, s]) || [])
|
|
|
|
const staffPerformance = new Map()
|
|
|
|
bookingsData?.forEach((booking: any) => {
|
|
const staffId = booking.staff_id
|
|
const staff = staffMap.get(staffId)
|
|
|
|
if (!staffPerformance.has(staffId)) {
|
|
staffPerformance.set(staffId, {
|
|
staffId,
|
|
displayName: staff?.display_name || 'Unknown',
|
|
role: staff?.role || 'Unknown',
|
|
totalBookings: 0,
|
|
totalRevenue: 0,
|
|
totalHours: 0
|
|
})
|
|
}
|
|
|
|
const perf = staffPerformance.get(staffId)
|
|
perf.totalBookings += 1
|
|
perf.totalRevenue += booking.total_amount || 0
|
|
|
|
const duration = booking.end_time_utc && booking.start_time_utc
|
|
? (new Date(booking.end_time_utc).getTime() - new Date(booking.start_time_utc).getTime()) / (1000 * 60 * 60)
|
|
: 0
|
|
perf.totalHours += duration
|
|
})
|
|
|
|
response.topPerformers = Array.from(staffPerformance.values())
|
|
.sort((a: any, b: any) => b.totalRevenue - a.totalRevenue)
|
|
.slice(0, 10)
|
|
}
|
|
|
|
if (includeActivity) {
|
|
// Get recent bookings
|
|
const { data: recentBookings } = await supabaseAdmin
|
|
.from('bookings')
|
|
.select('id, short_id, status, start_time_utc, end_time_utc, created_at, customer_id, service_id, staff_id')
|
|
.order('created_at', { ascending: false })
|
|
.limit(10)
|
|
|
|
// Get related data
|
|
const customerIds = recentBookings?.map(b => b.customer_id).filter(Boolean) || []
|
|
const serviceIds = recentBookings?.map(b => b.service_id).filter(Boolean) || []
|
|
const staffIds = recentBookings?.map(b => b.staff_id).filter(Boolean) || []
|
|
|
|
const [customers, services, staff] = await Promise.all([
|
|
customerIds.length > 0 ? supabaseAdmin.from('customers').select('id, first_name, last_name').in('id', customerIds) : Promise.resolve({ data: [] }),
|
|
serviceIds.length > 0 ? supabaseAdmin.from('services').select('id, name').in('id', serviceIds) : Promise.resolve({ data: [] }),
|
|
staffIds.length > 0 ? supabaseAdmin.from('staff').select('id, display_name').in('id', staffIds) : Promise.resolve({ data: [] })
|
|
])
|
|
|
|
const customerMap = new Map(customers.data?.map(c => [c.id, c]) || [])
|
|
const serviceMap = new Map(services.data?.map(s => [s.id, s]) || [])
|
|
const staffMap = new Map(staff.data?.map(s => [s.id, s]) || [])
|
|
|
|
const activityFeed = recentBookings?.map((booking: any) => {
|
|
const customer = customerMap.get(booking.customer_id)
|
|
const service = serviceMap.get(booking.service_id)
|
|
const staffMember = staffMap.get(booking.staff_id)
|
|
|
|
return {
|
|
id: booking.id,
|
|
type: 'booking',
|
|
action: booking.status === 'completed' ? 'completed' :
|
|
booking.status === 'confirmed' ? 'confirmed' :
|
|
booking.status === 'cancelled' ? 'cancelled' : 'created',
|
|
timestamp: booking.created_at,
|
|
bookingShortId: booking.short_id,
|
|
customerName: customer ? `${customer.first_name || ''} ${customer.last_name || ''}`.trim() : 'Unknown',
|
|
serviceName: service?.name || 'Unknown',
|
|
staffName: staffMember?.display_name || 'Unknown'
|
|
}
|
|
})
|
|
|
|
response.activityFeed = activityFeed
|
|
}
|
|
|
|
return NextResponse.json(response)
|
|
} catch (error) {
|
|
console.error('Aperture dashboard GET error:', error)
|
|
return NextResponse.json(
|
|
{ error: 'Internal server error' },
|
|
{ status: 500 }
|
|
)
|
|
}
|
|
}
|