Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions web/pay-insights/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ Cloud SQL connector uses IAM auth with the service's own identity, so no passwor
The home page, report page (`/reports/<id>`), filters, totals, table and CSV export
(`/api/v1/reports/<id>/export.csv`) come from the definition. In the SQL:

- Use named parameters: `:paymentMethod`. A `fiscalYear` filter provides `:fiscalYear`,
`:fiscalYearStart` and `:fiscalYearEnd` (UTC, matching how PAY stores dates).
- Use named parameters: `:paymentMethod`. A `period` filter (fiscal year or custom date range)
provides `:<key>Start` and `:<key>End` — UTC timestamps, end exclusive, matching how PAY stores dates.
A `fiscalYear` filter provides `:fiscalYear`, `:fiscalYearStart` and `:fiscalYearEnd`.
- Cast parameters that can be NULL, e.g. `(:paymentMethod::text IS NULL OR ...)`.
- Aggregate in SQL and don't add `LIMIT`.
44 changes: 24 additions & 20 deletions web/pay-insights/app/pages/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,18 @@ useHead({

// Runs on the server during page render; the session cookie is forwarded automatically
const { data: reports } = await useFetch('/api/v1/reports')
const { data: check, status } = await useFetch('/api/v1/warehouse/check')

// Small status line: checked in the browser after the page loads, so it doesn't slow the page down
const { status: warehouseStatus, data: warehouse } = useFetch('/api/v1/warehouse/check', { server: false, lazy: true })
const warehouseState = computed(() => {
if (warehouseStatus.value === 'success' && warehouse.value?.serverTime) {
return { dot: 'bg-green-600', text: t('page.home.warehouse.connected') }
}
if (warehouseStatus.value === 'error') {
return { dot: 'bg-red-600', text: t('page.home.warehouse.unavailable') }
}
return { dot: 'bg-gray-400', text: t('page.home.warehouse.checking') }
})
</script>

<template>
Expand All @@ -20,6 +31,18 @@ const { data: check, status } = await useFetch('/api/v1/warehouse/check')
<p class="mt-2 text-gray-600">
{{ $t('page.home.description') }}
</p>
<p
class="mt-2 flex items-center gap-2 text-sm text-gray-600"
role="status"
data-testid="warehouse-status"
>
<span
class="inline-block size-2 rounded-full"
:class="warehouseState.dot"
aria-hidden="true"
/>
{{ warehouseState.text }}
</p>
</div>

<h2 class="text-lg font-semibold">
Expand All @@ -46,24 +69,5 @@ const { data: check, status } = await useFetch('/api/v1/warehouse/check')
/>
</div>
</UCard>

<UCard data-testid="warehouse-check">
<h3 class="font-semibold">
{{ $t('page.home.warehouse.title') }}
</h3>
<p
v-if="status === 'success' && check?.serverTime"
class="text-sm"
>
{{ $t('page.home.warehouse.connected') }} —
{{ $t('page.home.warehouse.serverTime', { time: check.serverTime }) }}
</p>
<p
v-else
class="text-sm text-red-600"
>
{{ $t('page.home.warehouse.failed') }}
</p>
</UCard>
</section>
</template>
96 changes: 83 additions & 13 deletions web/pay-insights/app/pages/reports/[id].vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ interface ReportColumn {
}

const ALL = '__all__'
const CUSTOM_PERIOD = 'custom'
const PAGE_SIZE = 50

const route = useRoute()
Expand All @@ -22,13 +23,30 @@ const reportId = String(route.params.id)
// Filters live in the URL, so a report can be bookmarked or shared
const query = computed(() => route.query)
// lazy: navigate straight away and show a loading state while the warehouse query runs
const { data, status, error } = useFetch(`/api/v1/reports/${reportId}`, { query, lazy: true })
const { data: latest, status, error } = useFetch(`/api/v1/reports/${reportId}`, { query, lazy: true })

// Keep showing the last good result (and its filters) if a request fails, e.g. an invalid date range
const lastGood = shallowRef(latest.value)
watch(latest, (value) => {
if (value) {
lastGood.value = value
}
})
const data = computed(() => latest.value ?? lastGood.value)
const isFirstLoad = computed(() => status.value === 'pending' && !data.value)

useHead({
title: () => data.value?.report.title ?? 'PAY Insights'
})

// Breadcrumb: PAY Insights › <report title>, so the header back arrow returns home
watchEffect(() => {
route.meta.breadcrumbs = [
{ label: 'PAY Insights', to: localePath('/') },
{ label: data.value?.report.title ?? '' }
]
})

const currency = new Intl.NumberFormat('en-CA', { style: 'currency', currency: 'CAD' })
const number = new Intl.NumberFormat('en-CA')

Expand All @@ -45,9 +63,26 @@ function formatValue(value: unknown, format?: ReportColumn['format']): string {
return String(value)
}

function setQuery(patch: Record<string, string | undefined>) {
router.replace({ query: { ...route.query, ...patch } })
}

function setFilter(key: string, value: unknown) {
const next = { ...route.query, [key]: value === ALL ? undefined : String(value) }
router.replace({ query: next })
setQuery({ [key]: value === ALL ? undefined : String(value) })
}

/** Period: picking "Custom range" starts from the dates currently shown; a fiscal year clears them. */
function setPeriod(filter: { key: string, from?: string, to?: string }, value: unknown) {
const custom = value === CUSTOM_PERIOD
setQuery({
[filter.key]: String(value),
[`${filter.key}From`]: custom ? filter.from : undefined,
[`${filter.key}To`]: custom ? filter.to : undefined
})
}

function setPeriodDate(key: string, value: string | number) {
setQuery({ [key]: String(value) || undefined })
}

const filters = computed(() => (data.value?.filters ?? []).map(filter => ({
Expand Down Expand Up @@ -105,19 +140,54 @@ const exportUrl = computed(() => {
</div>

<div class="flex flex-wrap items-end gap-4">
<UFormField
<template
v-for="filter in filters"
:key="filter.key"
:label="filter.label"
>
<USelect
:model-value="filter.model"
:items="filter.items"
class="w-64"
:data-testid="`filter-${filter.key}`"
@update:model-value="value => setFilter(filter.key, value)"
/>
</UFormField>
<template v-if="filter.type === 'period'">
<UFormField :label="filter.label">
<USelect
:model-value="filter.model"
:items="filter.items"
class="w-48"
:data-testid="`filter-${filter.key}`"
@update:model-value="value => setPeriod(filter, value)"
/>
</UFormField>
<template v-if="filter.value === CUSTOM_PERIOD">
<UFormField :label="$t('page.report.from')">
<UInput
type="date"
:model-value="filter.from"
:max="filter.to"
:data-testid="`filter-${filter.key}-from`"
@change="(event: Event) => setPeriodDate(`${filter.key}From`, (event.target as HTMLInputElement).value)"
/>
</UFormField>
<UFormField :label="$t('page.report.to')">
<UInput
type="date"
:model-value="filter.to"
:min="filter.from"
:data-testid="`filter-${filter.key}-to`"
@change="(event: Event) => setPeriodDate(`${filter.key}To`, (event.target as HTMLInputElement).value)"
/>
</UFormField>
</template>
</template>
<UFormField
v-else
:label="filter.label"
>
<USelect
:model-value="filter.model"
:items="filter.items"
class="w-64"
:data-testid="`filter-${filter.key}`"
@update:model-value="value => setFilter(filter.key, value)"
/>
</UFormField>
</template>
<UButton
:href="exportUrl"
external
Expand Down
15 changes: 11 additions & 4 deletions web/pay-insights/i18n/locales/en-CA.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
export default {
// Overrides the nuxt-base default breadcrumb ("Service BC Connect")
connect: {
breadcrumb: {
default: 'PAY Insights'
}
},
header: {
logout: 'Log out'
},
Expand All @@ -10,16 +16,17 @@ export default {
reports: 'Reports',
view: 'View report',
warehouse: {
title: 'Data warehouse',
connected: 'Connected',
failed: 'Unable to reach the data warehouse.',
serverTime: 'Server time: {time}'
checking: 'Checking data warehouse…',
connected: 'Data warehouse connected',
unavailable: 'Data warehouse unavailable — reports may not load'
}
},
report: {
back: 'Back to reports',
export: 'Export CSV',
loading: 'Loading report…',
from: 'From',
to: 'To',
total: 'Total {label}',
empty: 'No results for these filters.',
error: 'Unable to load the report. Please try again later.',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export default defineEventHandler(async (event) => {
}

const suffix = filters
.map(filter => filter.value)
.map(filter => filter.value === CUSTOM_PERIOD ? `${filter.from}_${filter.to}` : filter.value)
.filter(value => value !== null)
.join('-')
const filename = `${report.id}${suffix ? `-${suffix}` : ''}.csv`.replace(/[^\w.-]/g, '_')
Expand Down
8 changes: 4 additions & 4 deletions web/pay-insights/server/reports/service-fees.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@ import { defineReport } from './types'
export default defineReport({
id: 'service-fees',
title: 'Service fees by product',
description: 'Service fees collected on paid invoices, by product, for one fiscal year (April 1 to March 31).',
description: 'Service fees collected on paid invoices, by product, for a fiscal year or a custom date range.',
note: 'Includes invoices with status PAID, by payment date in Pacific time. Refunded invoices are not included.',

filters: [
{ key: 'fiscalYear', type: 'fiscalYear', label: 'Fiscal year' },
{ key: 'period', type: 'period', label: 'Period' },
{
key: 'paymentMethod',
type: 'select',
Expand All @@ -26,8 +26,8 @@ export default defineReport({
FROM pay.invoices i
LEFT JOIN pay.corp_types ct ON ct.code = i.corp_type_code
WHERE i.invoice_status_code = 'PAID'
AND i.payment_date >= :fiscalYearStart::timestamp
AND i.payment_date < :fiscalYearEnd::timestamp
AND i.payment_date >= :periodStart::timestamp
AND i.payment_date < :periodEnd::timestamp
AND (:paymentMethod::text IS NULL OR i.payment_method_code = :paymentMethod::text)
GROUP BY 1, 2
ORDER BY 4 DESC, 2`,
Expand Down
13 changes: 12 additions & 1 deletion web/pay-insights/server/reports/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,17 @@ export interface FiscalYearFilter {
label: string
}

/**
* A fiscal year (quick pick) or a custom date range. Query string: `<key>=FY2025`, or
* `<key>=custom&<key>From=2025-05-01&<key>To=2025-06-30` (dates in Pacific time, inclusive).
* Provides `:<key>Start` and `:<key>End` to the SQL — UTC timestamps, end exclusive.
*/
export interface PeriodFilter {
key: string
type: 'period'
label: string
}

export interface SelectFilter {
key: string
type: 'select'
Expand All @@ -21,7 +32,7 @@ export interface SelectFilter {
allLabel?: string
}

export type ReportFilter = FiscalYearFilter | SelectFilter
export type ReportFilter = FiscalYearFilter | PeriodFilter | SelectFilter

export interface ReportColumn {
/** Column name returned by the SQL */
Expand Down
34 changes: 34 additions & 0 deletions web/pay-insights/server/utils/dates.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
const PACIFIC_HOUR = new Intl.DateTimeFormat('en-CA', {
timeZone: 'America/Vancouver',
hour: '2-digit',
hourCycle: 'h23'
})

/** Parses a `YYYY-MM-DD` date, or returns undefined if it isn't a real date. */
export function parseIsoDate(value: unknown): string | undefined {
const text = typeof value === 'string' ? value : ''
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text)
if (!match) {
return undefined
}
const [year, month, day] = match.slice(1).map(Number) as [number, number, number]
const date = new Date(Date.UTC(year, month - 1, day))
const valid = date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day
return valid ? text : undefined
}

export function addDays(date: string, days: number): string {
const [year, month, day] = date.split('-').map(Number) as [number, number, number]
return new Date(Date.UTC(year, month - 1, day + days)).toISOString().slice(0, 10)
}

/**
* Midnight Pacific on the given date, as a UTC timestamp (how PAY stores dates).
* Pacific midnight is 08:00 UTC in standard time and 07:00 UTC in daylight time.
*/
export function pacificMidnightAsUtc(date: string): string {
const [year, month, day] = date.split('-').map(Number) as [number, number, number]
const standardTimeGuess = new Date(Date.UTC(year, month - 1, day, 8))
const isDaylightTime = PACIFIC_HOUR.format(standardTimeGuess) === '01'
return `${date} ${isDaylightTime ? '07' : '08'}:00:00`
}
5 changes: 5 additions & 0 deletions web/pay-insights/server/utils/fiscalYear.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ export function fiscalYearRange(year: number): { start: string, end: string } {
return { start: `${year}-04-01 07:00:00`, end: `${year + 1}-04-01 07:00:00` }
}

/** Fiscal year as inclusive Pacific dates, e.g. 2025-04-01 to 2026-03-31. */
export function fiscalYearDates(year: number): { from: string, to: string } {
return { from: `${year}-04-01`, to: `${year + 1}-03-31` }
}

/** Returns a valid fiscal year from user input, or the current one if missing. Throws on invalid input. */
export function parseFiscalYear(value: unknown, today = new Date()): number {
const current = currentFiscalYear(today)
Expand Down
Loading
Loading