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 Dashboard → Reports 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