Skip to main content

Generating Reports

This guide covers how to generate, configure, and run reports using the Shyfts reporting dashboard and API.


Overview

Accessing Reports

  1. Navigate to DashboardReports from the sidebar
  2. The Reports & Analytics dashboard loads automatically
  3. Select a report type from the tab navigation
  4. Configure date range and filters
  5. Click Refresh to run the report

Report Dashboard Interface

// Source: src/components/reporting/ReportingDashboard.tsx:46-55
const [activeTab, setActiveTab] = useState<
'overview' | 'performance' | 'scheduling' | 'attendance' | 'skills' | 'hours' | 'leave' | 'cost' | 'compliance'
>('overview')
const [dateRange, setDateRange] = useState<DateRange>({
start_date: moment.tz(UK_TIMEZONE).subtract(30, 'days').format('YYYY-MM-DD'),
end_date: moment.tz(UK_TIMEZONE).format('YYYY-MM-DD'),
period_type: 'MONTH'
})

Date Range Selection

Default Date Range

By default, reports cover the last 30 days:

  • Start Date: 30 days ago
  • End Date: Today
  • Period Type: MONTH

Date Range Options

// Source: src/types/reporting.ts:27-33
interface DateRange {
start_date: string // YYYY-MM-DD format
end_date: string // YYYY-MM-DD format
period_type: 'DAY' | 'WEEK' | 'MONTH' | 'QUARTER' | 'YEAR' | 'CUSTOM'
}

Period Types

PeriodDescriptionTypical Use
DAYSingle day reportDaily snapshots
WEEK7-day periodWeekly summaries
MONTHCalendar monthMonthly reviews
QUARTER3-month periodQuarterly analysis
YEARFull yearAnnual reports
CUSTOMUser-definedFlexible date ranges

Using the Date Picker

The DateRangeSelector component provides:

  1. Quick presets - This week, Last week, This month, etc.
  2. Custom range - Select specific start and end dates
  3. Period display - Shows selected range in header
// Header display example
<p className="text-secondary-text mt-1">
{moment.tz(dateRange.start_date, 'YYYY-MM-DD', UK_TIMEZONE).format('D MMM YYYY')} -
{moment.tz(dateRange.end_date, 'YYYY-MM-DD', UK_TIMEZONE).format('D MMM YYYY')}
</p>

Filtering Reports

Available Filters

// Source: src/types/reporting.ts:35-45
interface ReportFilters {
staff_ids?: string[] // Specific staff members
role_ids?: string[] // Filter by roles
room_ids?: string[] // Filter by rooms
shift_types?: string[] // Shift type filter
shift_statuses?: string[] // Shift status filter
employment_types?: string[] // Full-time, part-time, etc.
leave_type_ids?: string[] // Leave types
time_entry_statuses?: string[] // Time entry states
skills?: string[] // Skill filters
}

Opening Filters Panel

// Source: src/components/reporting/ReportingDashboard.tsx:573-585
<button
onClick={() => setShowFiltersPanel(true)}
className="form-button secondary flex items-center gap-2"
>
<Filter className="w-4 h-4" />
Filters
{Object.keys(filters).filter(key => filters[key]?.length).length > 0 && (
<span className="ml-1 px-2 py-0.5 rounded-full bg-accent-coral text-xs font-semibold">
{activeFilterCount}
</span>
)}
</button>

Filter Examples

FilterExample ValuesEffect
Staff IDs['staff-uuid-1', 'staff-uuid-2']Report only these staff
Role IDs['role-nurse', 'role-doctor']Filter by specific roles
Room IDs['room-1', 'room-2']Show only selected rooms
Employment Types['full-time', 'part-time']Filter by contract type

Clearing Filters

  • Click the filter badge to open the panel
  • Use "Clear All" to reset filters
  • Individual filters can be deselected

Running Reports

Manual Refresh

Click the Refresh button to re-run the current report:

// Source: src/components/reporting/ReportingDashboard.tsx:602-610
<button
onClick={() => fetchReportData({ cacheBypass: true })}
disabled={loading}
className="form-button flex items-center gap-2"
>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
Refresh
</button>

Cache Bypass

Use cacheBypass: true to force fresh data:

// Fresh data request
fetchReportData({ cacheBypass: true })

// Cached data (faster)
fetchReportData()

Automatic Updates

Reports automatically refresh when:

  1. Date range changes - New period selected
  2. Filters change - Filter criteria updated
  3. Tab switches - Different report type selected
// Source: src/components/reporting/ReportingDashboard.tsx:324-327
useEffect(() => {
fetchReportData()
}, [fetchReportData, dateRange, filters])

Live Updates

Enabling Live Mode

Toggle live updates for real-time data:

// Source: src/components/reporting/ReportingDashboard.tsx:594-601
<button
onClick={() => setLiveEnabled((v) => !v)}
className="form-button secondary"
disabled={!user?.company_id}
title={!user?.company_id ? 'Company scope required for live updates' : undefined}
>
Live: {liveEnabled ? (liveStatus === 'live' ? 'On' : '…') : 'Off'}
</button>

Live Status Indicators

StatusDisplayMeaning
offLive: OffNot subscribed
connectingLive: …Establishing connection
liveLive: OnReceiving updates
errorLive: ErrorConnection failed

Real-time Subscriptions

// Source: src/components/reporting/ReportingDashboard.tsx:346-361
const tablesToWatch: Array<'time_entries' | 'shifts' | 'leave_requests'> =
activeTab === 'scheduling'
? ['shifts']
: activeTab === 'leave'
? ['leave_requests']
: activeTab === 'hours'
? ['time_entries', 'shifts']
: ['time_entries', 'shifts', 'leave_requests']

The system monitors relevant database tables and auto-refreshes when changes occur.


Report API

Endpoint

POST /api/reports/run

Request Schema

// Source: src/app/api/reports/run/route.ts:46-57
const reportRunSchema = z.object({
reportType: reportTypeSchema, // Required: Report type
dateRange: dateRangeSchema, // Required: Date range
filters: z.record(z.unknown()).optional(), // Optional: Filters
pagination: paginationSchema, // Optional: Page settings
options: z.object({
cacheBypass: z.boolean().optional() // Skip cache
}).optional(),
companyId: z.string().uuid().optional() // For System Admins
})

Example Request

// Generate staff performance report
const response = await fetch('/api/reports/run', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
reportType: 'STAFF_PERFORMANCE',
dateRange: {
start_date: '2025-01-01',
end_date: '2025-01-31',
period_type: 'MONTH'
},
filters: {
role_ids: ['role-uuid-1']
},
pagination: {
page: 1,
pageSize: 50
}
})
})

const data = await response.json()

Response Format

// Successful response
{
success: true,
data: [...], // Report data
metadata: {
requestId: 'uuid',
generatedAt: '2025-01-14T10:30:00Z',
parametersHash: 'sha256-hash',
cache: {
hit: false,
ttlSeconds: 300
},
timing: 245, // ms
version: 'reports-v1',
pagination: {
page: 1,
pageSize: 50,
total: 125,
totalPages: 3,
hasNext: true,
hasPrev: false
}
}
}

Pagination

Paginated Reports

These reports support pagination:

  • Staff Performance
  • Attendance Summary
  • Hours Tracking
  • Leave Analysis

Pagination Controls

// Source: src/components/reporting/ReportingDashboard.tsx:499-554
const renderPaginationControls = (input: {
label: string
pagination: PaginationMeta | null
page: number
pageSize: number
setPage: (page: number) => void
setPageSize: (pageSize: number) => void
}) => {
return (
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div className="text-sm text-secondary-text">
{input.label}: Showing {from}-{to} of {total} (Page {page} of {totalPages})
</div>
<div className="flex items-center gap-2 flex-wrap">
<select
value={pageSize}
onChange={(e) => {
input.setPage(1)
input.setPageSize(Number(e.target.value))
}}
className="px-3 py-2 rounded-lg bg-white/10 border border-white/20"
>
{[25, 50, 100, 200].map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
<button disabled={!pagination?.hasPrev} onClick={() => setPage(page - 1)}>
Prev
</button>
<button disabled={!pagination?.hasNext} onClick={() => setPage(page + 1)}>
Next
</button>
</div>
</div>
)
}

Page Size Options

SizeUse Case
25Quick browsing
50Default view
100Detailed review
200Maximum per page

Report Caching

Cache Behaviour

Reports are cached for improved performance:

// Source: src/app/api/reports/run/route.ts:197-220
if (!cacheBypass) {
const cached = getCachedReport<{ data: unknown; pagination?: PaginationMeta }>(cacheKey)
if (cached) {
logger.info('Report cache hit', {
reportType: parsedBody.data.reportType,
ttlSeconds: cached.ttlSeconds,
durationMs: Date.now() - startedAt
})
return ApiResponses.success(cached.value.data, {
cache: { hit: true, ttlSeconds: cached.ttlSeconds }
})
}
}

Cache Key Generation

// Source: src/app/api/reports/run/route.ts:59-71
function hashParameters(value: unknown): string {
const json = JSON.stringify(value, (_key, val) => {
if (!val || typeof val !== 'object' || Array.isArray(val)) return val
const record = val as Record<string, unknown>
return Object.keys(record)
.sort()
.reduce<Record<string, unknown>>((acc, key) => {
acc[key] = record[key]
return acc
}, {})
})
return crypto.createHash('sha256').update(json).digest('hex')
}

Cache TTL

Default cache duration: 5 minutes (300 seconds)

To bypass cache:

  1. Click Refresh button
  2. Enable Live mode
  3. Use cacheBypass: true in API calls

Error Handling

Error Display

// Source: src/components/reporting/ReportingDashboard.tsx:614-631
{reportError && (
<div className="card-glass p-4 rounded-xl border border-red-500/30 bg-red-500/10">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<div className="text-white font-semibold">Report failed to load</div>
<div className="text-red-200 text-sm">
{reportError.message}
</div>
</div>
<button
onClick={() => fetchReportData({ cacheBypass: true })}
className="form-button secondary"
>
Retry
</button>
</div>
</div>
)}

Common Errors

ErrorCauseSolution
UnauthorizedSession expiredRe-login
ForbiddenInsufficient permissionsContact admin
Too many requestsRate limit exceededWait and retry
Invalid report requestBad parametersCheck date format

Rate Limiting

// Source: src/app/api/reports/run/route.ts:130-137
const rate = reportRunLimiter.checkLimit(`reports:${user.id}`)
if (!rate.allowed) {
return ApiResponses.tooManyRequests('Too many report requests, please wait a moment.', {
requestId,
resetTime: rate.resetTime,
remainingRequests: rate.remainingRequests
})
}

Report Tabs

Tab Navigation

// Source: src/components/reporting/ReportingDashboard.tsx:487-497
const tabs = [
{ id: 'overview', label: 'Overview', icon: BarChart3 },
{ id: 'performance', label: 'Performance', icon: TrendingUp },
{ id: 'scheduling', label: 'Scheduling', icon: Calendar },
{ id: 'attendance', label: 'Attendance', icon: Clock },
{ id: 'skills', label: 'Skills', icon: Users },
{ id: 'hours', label: 'Hours', icon: Clock },
{ id: 'leave', label: 'Leave', icon: Calendar },
{ id: 'cost', label: 'Cost', icon: DollarSign },
{ id: 'compliance', label: 'Compliance', icon: ShieldCheck }
] as const

Overview Tab

The Overview tab runs all 8 reports simultaneously:

// Source: src/components/reporting/ReportingDashboard.tsx:266-278
case 'overview':
await Promise.all([
fetchPerformanceData(options),
fetchSchedulingData(options),
fetchAttendanceData(options),
fetchSkillsData(options),
fetchHoursData(options),
fetchLeaveData(options),
fetchCostData(options),
fetchComplianceData(options)
])
break

Best Practices

For Optimal Performance

  1. Use filters - Narrow down data scope
  2. Reasonable date ranges - Avoid very long periods
  3. Appropriate page sizes - Balance detail vs speed
  4. Leverage caching - Avoid unnecessary refreshes

For Accurate Reports

  1. Verify date range - Check start/end dates are correct
  2. Review filters - Ensure filters match requirements
  3. Compare periods - Use consistent date ranges for comparisons
  4. Check data freshness - Note cache status

For Regular Reporting

FrequencyRecommended Reports
DailyAttendance, Hours
WeeklyPerformance, Scheduling
MonthlyCost, Leave, Compliance
QuarterlyAll reports for review


Source Files:

  • src/components/reporting/ReportingDashboard.tsx - Dashboard component
  • src/app/api/reports/run/route.ts - Report execution API
  • src/types/reporting.ts - Type definitions
  • src/lib/cache/reportCache.ts - Caching utilities
  • src/services/reportingService.ts - Report generation service