Compare commits

...
2 Commits
Author SHA1 Message Date
joshua fb42d6ac19 Merge branch 'feat/dashboard-palette' 2026-10-28 14:17:23 +02:00
joshuaandClaude Opus 5.5 2fc9e08f89 Dashboard Today and Waiting for you; Ctrl+K command palette (1.49.0)
Staff, supervisor and admin dashboards start with what's on today
(checked in vs places, children in now, volunteers here; money in today
and this week for supervisors) and, for supervisors and admins, what's
waiting for someone (unpaid registrations, consent, waitlists, cover
requests, roster gaps; for admins cashups, reconciling, privacy
requests, failed webhooks), each linking to where it's dealt with.
Ctrl+K opens a palette of pages, settings, reports and actions for the
role, with search: events, payments by amount, people and
registrations (admins). All campus-scoped. Bulk actions and saved
filters follow as 1.49.1 and 1.49.2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 14:17:22 +02:00
30 changed files with 924 additions and 29 deletions
+21
View File
@@ -7,6 +7,27 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [1.49.0] - 2026-10-28
### Added
- **Today on the dashboard** (staff, supervisors, admins): what's on today — each event, or its
session today — with people checked in so far against places, children in now at kids church,
and volunteers here against rostered (and how many short). Supervisors and admins also see money
in today and over the last 7 days. Refreshes every 30 seconds; follows campus scoping.
- **Waiting for you** (supervisors, admins): what needs someone, each linking to where it's dealt
with — unpaid registrations for events in the next two weeks, young people waiting for parental
consent, waitlists, cover requests, serving roles in the next week short of people, and for
admins past events to cash up, provider payments to reconcile, open privacy requests and failed
webhook deliveries.
- **Command palette — Ctrl K (⌘K)** anywhere in the dashboard, or "Search or jump to…" on the
dashboards: pages, settings tabs and reports for the person's role, actions ("New event", "Scan
tickets", "Record a payment"…), and search — events for everyone; payments by amount ("R450") for
supervisors; people and registrations (by reference) for admins. Keyboard: ↑/↓, Enter, Esc.
- Deep links: `/dashboard/supervisor/events?edit=<id>` and `?new=1`; `/dashboard/admin/registrations?q=&status=&event=`;
`/dashboard/admin/users?q=`.
- `GET /api/dashboard/today`, `GET /api/dashboard/search?q=`.
## [1.48.2] - 2026-10-28
### Added
+12 -3
View File
@@ -414,9 +414,18 @@ logout controls. Disconnected sessions are recovered automatically.
## Supervisor tools
- **Dashboard** — KPIs (active events, revenue, donations, registrations,
tickets sold, each vs. the previous month), revenue trend chart, top events,
and recent scans. Tiles link to the matching report.
- **Dashboard** — **Today** (each event or session on today: checked in so far
against places, children in now, volunteers here; money in today and this
week) and **Waiting for you** (unpaid registrations for the next two weeks,
parental consent outstanding, waitlists, cover requests, roster gaps; for
admins also cashups due, payments to reconcile, privacy requests, failed
webhooks — each links to where it's dealt with); then KPIs (active events,
revenue, donations, registrations, tickets sold, each vs. the previous month),
revenue trend chart, top events, and recent scans. Staff see Today too.
- **Command palette (Ctrl K / ⌘K)** — anywhere in the dashboard: pages, settings
and reports for the role, actions ("New event", "Scan tickets"…), and search:
events; payments by amount ("R450") for supervisors; people and registrations
for admins. Follows campus scoping.
- **Manage events** — the 9-step event wizard, ticket options, variants,
sections, form and attachments.
- **At the door** — search by name/phone/ticket ID, walk-in registration,
+10
View File
@@ -12,6 +12,16 @@ Write for church admins, not developers: no environment variables, endpoints or
developer log is CHANGELOG.md. A tagged version without a section here shows "No details given".
-->
## 1.49.0
- **Today, at a glance.** The dashboard now starts with what's on today: each event with how many
people are checked in, children in at kids church, and volunteers here — plus money in today and
this week.
- **Waiting for you.** One list of what needs someone — unpaid registrations, consent forms still
outstanding, waitlists, volunteers asking for cover, events to cash up — each a click away.
- **Ctrl K to find anything.** Press Ctrl K anywhere in the dashboard to jump to a page or setting,
start "New event", or find an event, a payment by amount ("R450") or a person.
## 1.48.2
- No changes for churches in this version — it adds demo data for showing Cross Events to other
+7
View File
@@ -933,6 +933,13 @@ Allergies, medical and custody notes are stored encrypted. Staff routes are camp
Since 1.46.0 board rooms also carry `rostered: [{ userId, name, checkedIn }]` (volunteers on a serving
role linked to the room); checked-in ones are counted in `leaders`.
## Dashboard and command palette
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/dashboard/today` | staff+ | `{ events: [{ id, title, startsAt, endsAt, sessionLabel, places, checkedIn, kidsIn, volunteers }], money: { today, todayCount, week } \| null, pending: [{ key, label, count, href, tone }] }` — money and pending for supervisors/admins; campus-scoped |
| GET | `/api/dashboard/search?q=` | staff+ | `{ results: [{ type: event\|payment\|person\|registration, id, title, subtitle, href }] }` — payments by amount for supervisors, people and registrations for admins |
## Event series
| Method | Path | Auth | Description |
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "event-management-backend",
"version": "1.48.2",
"version": "1.49.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "event-management-backend",
"version": "1.48.2",
"version": "1.49.0",
"hasInstallScript": true,
"dependencies": {
"@prisma/adapter-pg": "^7.10.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "event-management-backend",
"version": "1.48.2",
"version": "1.49.0",
"description": "Event Management System Backend",
"main": "src/index.js",
"scripts": {
+6
View File
@@ -188,6 +188,7 @@ app.use('/api/volunteers', require('./routes/volunteerRoutes'));
app.use('/api/series', require('./routes/seriesRoutes'));
app.use('/api/season-passes', require('./routes/seasonPassRoutes'));
app.use('/api/outbound-webhooks', require('./routes/outboundWebhookRoutes'));
app.use('/api/dashboard', require('./routes/dashboardRoutes'));
app.use('/api/users', userRoutes);
app.use('/api/events', eventRoutes);
app.use('/api/registrations', registrationRoutes);
@@ -1012,6 +1013,11 @@ app.get('/docs', async (req, res) => {
{ method:'GET', path:'/api/series/:id/compare', auth:'supervisor+', desc:'Year on year: registrations, tickets, income (as on the dashboard), checked in (average per session on multi-session events) and attendance rate per event in the series, oldest first, with the % change from the one before. Campus-scoped staff: their campuses\x27 events only' },
]},
{ title: 'Dashboard and command palette', base: '/api/dashboard', endpoints: [
{ method:'GET', path:'/api/dashboard/today', auth:'staff+', desc:'Events on today (or with a session today): places, people checked in today, children in now, volunteers rostered/checked in. Supervisors and admins also get money in (today, last 7 days) and "waiting" items: unpaid registrations for the next two weeks, parental consent, waitlist, cover requests, roster gaps, and for admins cashups due, provider payments to reconcile, privacy requests, failed webhooks. Campus-scoped' },
{ method:'GET', path:'/api/dashboard/search?q=', auth:'staff+', desc:'Command palette search: events; supervisors also payments by amount ("R450"); admins also people and registrations by reference. { results: [{ type, id, title, subtitle, href }] }' },
]},
{ title: 'Outbound webhooks', base: '/api/outbound-webhooks', endpoints: [
{ method:'GET', path:'/api/outbound-webhooks', auth:'admin', desc:'Endpoints (with last-week delivery counts per status) and the event types: registration.created, registration.paid, registration.cancelled, payment.received, payment.refunded, ticket.checked_in, user.created, event.published' },
{ method:'POST', path:'/api/outbound-webhooks', auth:'admin', desc:'Add an endpoint (https, public address). The answer holds the signing secret — shown only now. Also added to the POPIA operator register. Audited as webhook_endpoint_changed',
+23
View File
@@ -0,0 +1,23 @@
const express = require('express');
const router = express.Router();
const { protect, staff } = require('../middleware/authMiddleware');
const { scopeList } = require('../utils/campusScope');
const dashboard = require('../utils/dashboard');
// Both follow the person's campus scope inside utils/dashboard.js.
// @desc Today's events and how they're going, money in, and what's waiting (1.49.0)
// @route GET /api/dashboard/today
// @access Private/Staff (money and waiting items for supervisors and admins)
router.get('/today', protect, staff, scopeList, async (req, res) => {
try { res.json(await dashboard.today(req)); } catch (e) { res.status(400).json({ message: e.message }); }
});
// @desc Command palette search (?q=): events; supervisors also payments by amount; admins people and registrations
// @route GET /api/dashboard/search
// @access Private/Staff
router.get('/search', protect, staff, scopeList, async (req, res) => {
try { res.json({ results: await dashboard.search(req, req.query.q) }); } catch (e) { res.status(400).json({ message: e.message }); }
});
module.exports = router;
+249
View File
@@ -0,0 +1,249 @@
/**
* The home dashboard for staff, supervisors and admins (1.49.0): what's on today, money in, and
* what's waiting for someone — plus the search behind the Ctrl+K command palette.
*
* Everything follows the person's campus scope (utils/campusScope.js): a campus-limited
* supervisor sees their campuses' events and church-wide ones. Items only some roles can act on
* (cashups, provider payments, privacy requests, webhooks) are shown to those roles only.
*/
const prisma = require('../config/db');
const { scopeFor, eventWhere } = require('./campusScope');
const DAY = 864e5;
// The same "real money" as the dashboards: everything except legs that apply an earlier donation.
const EXCLUDE_DONATION_LEGS = { NOT: { AND: [{ isDonation: false }, { originalPaymentId: { not: null } }, { amount: { gt: 0 } }] } };
function dayBounds(now = new Date()) {
const start = new Date(now); start.setHours(0, 0, 0, 0);
return { start, end: new Date(start.getTime() + DAY - 1) };
}
const isAdmin = req => req.user?.role === 'admin';
const isSupervisor = req => ['admin', 'supervisor'].includes(req.user?.role);
/** Events on today (single-date events running today, or a session today), with how it's going. */
async function todaysEvents(scopeWhere, now = new Date()) {
const { start, end } = dayBounds(now);
const events = await prisma.event.findMany({
where: {
AND: [scopeWhere, { isActive: true }, {
OR: [
{ scheduleType: 'single', startDate: { lte: end }, endDate: { gte: start } },
{ scheduleType: 'sessions', sessions: { some: { startsAt: { gte: start, lte: end }, cancelledAt: null } } },
],
}],
},
orderBy: { startDate: 'asc' },
select: {
id: true, title: true, startDate: true, endDate: true, location: true, scheduleType: true, kidsCheckIn: true, isHidden: true,
campus: { select: { id: true, name: true, slug: true, colour: true } },
sessions: { where: { startsAt: { gte: start, lte: end }, cancelledAt: null }, orderBy: { startsAt: 'asc' }, select: { id: true, label: true, startsAt: true, endsAt: true } },
},
});
return Promise.all(events.map(async e => {
const session = e.sessions[0] || null;
const [places, scans, kidsIn, roles] = await Promise.all([
prisma.ticket.aggregate({ where: { eventId: e.id, registrationOption: { registration: { status: { not: 'cancelled' } } } }, _sum: { quantity: true } }),
prisma.ticketUsage.aggregate({ where: { ticket: { eventId: e.id }, scannedAt: { gte: start, lte: end } }, _sum: { quantityRedeemed: true } }),
e.kidsCheckIn ? prisma.kidsCheckIn.count({ where: { eventId: e.id, checkedOutAt: null, checkedInAt: { gte: start, lte: end } } }) : Promise.resolve(null),
prisma.servingRole.findMany({
where: { eventId: e.id, OR: [{ sessionId: null }, ...(session ? [{ sessionId: session.id }] : [])] },
select: { needed: true, slots: { select: { checkedInAt: true } } },
}),
]);
return {
id: e.id, title: e.title, location: e.location, campus: e.campus, isHidden: e.isHidden, kidsCheckIn: e.kidsCheckIn,
startsAt: session ? session.startsAt : e.startDate, endsAt: session ? session.endsAt : e.endDate,
sessionLabel: session ? session.label : null,
places: places._sum.quantity || 0,
checkedIn: scans._sum.quantityRedeemed || 0,
kidsIn,
volunteers: roles.length ? {
needed: roles.reduce((s, r) => s + r.needed, 0),
rostered: roles.reduce((s, r) => s + r.slots.length, 0),
checkedIn: roles.reduce((s, r) => s + r.slots.filter(x => x.checkedInAt).length, 0),
} : null,
};
}));
}
/** Money in today and over the last 7 days (the person's events, or everything for the unscoped). */
async function moneyIn(scope, now = new Date()) {
const { start } = dayBounds(now);
const weekStart = new Date(start.getTime() - 6 * DAY);
const where = from => ({ ...EXCLUDE_DONATION_LEGS, createdAt: { gte: from }, ...(scope ? { event: eventWhere(scope) } : {}) });
const [today, week] = await Promise.all([
prisma.payment.aggregate({ where: where(start), _sum: { amount: true }, _count: { _all: true } }),
prisma.payment.aggregate({ where: where(weekStart), _sum: { amount: true } }),
]);
return { today: today._sum.amount || 0, todayCount: today._count._all, week: week._sum.amount || 0 };
}
/**
* What's waiting for someone: each { key, label, count, href, tone }, only those with a count.
* `href` takes the person to where it's dealt with.
*/
async function pendingItems(req, scope, now = new Date()) {
const upcoming = { startDate: { gte: now } };
const scoped = (extra = {}) => ({ AND: [eventWhere(scope), extra] });
const soon = new Date(now.getTime() + 14 * DAY);
const week = new Date(now.getTime() + 7 * DAY);
const items = [];
const push = async (key, fn) => {
try {
const item = await fn();
if (item && item.count > 0) items.push({ key, ...item });
} catch (e) {
console.warn(`[dashboard] ${key}:`, e?.message || e);
}
};
await Promise.all([
push('unpaid', async () => ({
label: 'Unpaid or part-paid registrations for events in the next two weeks',
count: await prisma.registration.count({ where: { status: { in: ['pending', 'partial_paid'] }, event: scoped({ startDate: { gte: now, lte: soon }, isActive: true }) } }),
href: isAdmin(req) ? '/dashboard/admin/registrations?status=pending' : '/dashboard/supervisor/reports?report=regStatus',
tone: 'amber',
})),
push('consent', async () => {
const pending = await prisma.consentRequest.findMany({ where: { status: 'pending', event: scoped(upcoming) }, select: { attendeeId: true }, distinct: ['attendeeId'] });
const given = new Set((await prisma.consentRequest.findMany({ where: { status: 'given', attendeeId: { in: pending.map(p => p.attendeeId) } }, select: { attendeeId: true } })).map(g => g.attendeeId));
return { label: 'Young people still waiting for parental consent', count: pending.filter(p => !given.has(p.attendeeId)).length, href: '/dashboard/supervisor/consent', tone: 'rose' };
}),
push('waitlist', async () => ({
label: 'People on a waitlist',
count: await prisma.waitlistEntry.count({ where: { status: 'waiting', event: scoped(upcoming) } }),
href: '/dashboard/supervisor/waitlist',
tone: 'blue',
})),
push('cover', async () => ({
label: 'Volunteers asking for someone to cover their shift',
count: await prisma.volunteerSwap.count({ where: { status: 'open', role: { event: scoped({ endDate: { gte: now } }) } } }),
href: '/dashboard/supervisor/volunteers',
tone: 'violet',
})),
push('roster', async () => {
const roles = await prisma.servingRole.findMany({
where: {
event: scoped({ isActive: true }),
OR: [{ session: { startsAt: { gte: now, lte: week } } }, { sessionId: null, event: { startDate: { gte: now, lte: week } } }],
},
select: { needed: true, _count: { select: { slots: true } } },
});
return { label: 'Serving roles in the next week that still need people', count: roles.filter(r => r._count.slots < r.needed).length, href: '/dashboard/supervisor/volunteers', tone: 'violet' };
}),
isAdmin(req) && push('cashup', async () => ({
label: 'Past events still to cash up',
count: await prisma.event.count({ where: { cashupStatus: 'open', endDate: { lt: new Date(now.getTime() - DAY), gte: new Date(now.getTime() - 120 * DAY) }, payments: { some: {} } } }),
href: '/dashboard/admin/cashup',
tone: 'green',
})),
(isAdmin(req) || (isSupervisor(req) && !scope)) && push('reconcile', async () => ({
label: 'Online payments that didn\'t match a registration (reconcile them)',
count: await prisma.providerTransaction.count({ where: { reconciled: false, ignored: false } }),
href: '/dashboard/supervisor/payments',
tone: 'amber',
})),
isAdmin(req) && push('privacy', async () => ({
label: 'Privacy requests to answer',
count: await prisma.dataRequest.count({ where: { status: 'open' } }),
href: '/dashboard/admin/data-requests',
tone: 'rose',
})),
isAdmin(req) && push('webhooks', async () => ({
label: 'Webhook deliveries that failed this week',
count: await prisma.webhookDelivery.count({ where: { status: 'failed', createdAt: { gte: new Date(now.getTime() - 7 * DAY) } } }),
href: '/dashboard/admin/settings?tab=webhooks',
tone: 'rose',
})),
].filter(Boolean));
const order = ['privacy', 'consent', 'unpaid', 'cover', 'roster', 'waitlist', 'reconcile', 'cashup', 'webhooks'];
return items.sort((a, b) => order.indexOf(a.key) - order.indexOf(b.key));
}
async function today(req, now = new Date()) {
const scope = await scopeFor(req);
const [events, money, pending] = await Promise.all([
todaysEvents(eventWhere(scope), now),
isSupervisor(req) ? moneyIn(scope, now) : Promise.resolve(null),
isSupervisor(req) ? pendingItems(req, scope, now) : Promise.resolve([]),
]);
return { events, money, pending };
}
// ─── Command palette search ──────────────────────────────────────────────────
/** "R450", "450", "r 450.50" → 450.5; anything else → null. Pure. */
function amountOf(q) {
const m = /^r?\s*(\d{1,7}(?:[.,]\d{1,2})?)$/i.exec(String(q || '').trim());
return m ? parseFloat(m[1].replace(',', '.')) : null;
}
/**
* Results for the command palette: events (everyone with dashboard access), and for supervisors
* payments by amount ("R450"); for admins also people and registrations. Each { type, id, title,
* subtitle, href }. Up to 5 of each kind.
*/
async function search(req, rawQuery, now = new Date()) {
const q = String(rawQuery || '').trim().slice(0, 80);
if (q.length < 2) return [];
const scope = await scopeFor(req);
const contains = { contains: q, mode: 'insensitive' };
const out = [];
const supervisor = isSupervisor(req);
const fmtDate = d => new Date(d).toLocaleDateString('en-ZA', { day: 'numeric', month: 'short', year: 'numeric' });
const events = await prisma.event.findMany({
where: { AND: [eventWhere(scope), { title: contains }] },
orderBy: { startDate: 'desc' }, take: 5,
select: { id: true, title: true, startDate: true, isActive: true, campus: { select: { name: true } } },
});
for (const e of events) {
out.push({
type: 'event', id: e.id, title: e.title,
subtitle: [fmtDate(e.startDate), e.campus?.name, e.isActive ? null : 'inactive'].filter(Boolean).join(' · '),
href: supervisor ? `/dashboard/supervisor/events?edit=${e.id}` : `/events/${e.id}`,
});
}
const amount = amountOf(q);
if (supervisor && amount !== null) {
const payments = await prisma.payment.findMany({
where: { amount: { in: [amount, -amount] }, createdAt: { gte: new Date(now.getTime() - 365 * DAY) }, ...(scope ? { event: eventWhere(scope) } : {}) },
orderBy: { createdAt: 'desc' }, take: 5,
select: { id: true, amount: true, method: true, createdAt: true, registrationId: true, user: { select: { name: true } }, event: { select: { title: true } } },
});
for (const p of payments) {
out.push({
type: 'payment', id: p.id, title: `R${p.amount.toFixed(2)} — ${p.user?.name || 'Unknown'}`,
subtitle: [p.method, p.event?.title, fmtDate(p.createdAt)].filter(Boolean).join(' · '),
href: isAdmin(req) && p.registrationId ? `/dashboard/admin/registrations?q=${p.registrationId}` : '/dashboard/supervisor/payments',
});
}
}
if (isAdmin(req)) {
const people = await prisma.user.findMany({
where: { isActive: true, OR: [{ name: contains }, { email: contains }, { phoneNumber: { contains: q.replace(/\s+/g, '') } }] },
orderBy: { name: 'asc' }, take: 5,
select: { id: true, name: true, email: true, role: true },
});
for (const u of people) {
out.push({ type: 'person', id: u.id, title: u.name, subtitle: [u.email, u.role !== 'user' ? u.role : null].filter(Boolean).join(' · '), href: `/dashboard/admin/users?q=${encodeURIComponent(u.email || u.name)}` });
}
// A registration by its reference (the start of its id), as on emails and invoices.
if (/^[0-9a-f-]{6,36}$/i.test(q)) {
const regs = await prisma.registration.findMany({
where: { id: { startsWith: q.toLowerCase() } }, take: 5,
select: { id: true, status: true, user: { select: { name: true } }, event: { select: { title: true } } },
});
for (const r of regs) {
out.push({ type: 'registration', id: r.id, title: `Registration ${r.id.slice(0, 8)} — ${r.user?.name || ''}`.trim(), subtitle: [r.event?.title, r.status].filter(Boolean).join(' · '), href: `/dashboard/admin/registrations?q=${r.id}` });
}
}
}
return out;
}
module.exports = { today, todaysEvents, moneyIn, pendingItems, search, amountOf, dayBounds };
+86
View File
@@ -0,0 +1,86 @@
const count = jest.fn().mockResolvedValue(2);
const mockDb = {
registration: { count, findMany: jest.fn().mockResolvedValue([]) },
consentRequest: { findMany: jest.fn().mockResolvedValue([]) },
waitlistEntry: { count },
volunteerSwap: { count },
servingRole: { findMany: jest.fn().mockResolvedValue([{ needed: 3, _count: { slots: 1 } }, { needed: 2, _count: { slots: 2 } }]) },
event: { count, findMany: jest.fn().mockResolvedValue([]) },
providerTransaction: { count },
dataRequest: { count },
webhookDelivery: { count },
payment: { findMany: jest.fn().mockResolvedValue([]) },
user: { findMany: jest.fn().mockResolvedValue([]) },
};
jest.mock('../src/config/db', () => mockDb);
const mockScope = jest.fn();
jest.mock('../src/utils/campusScope', () => ({
scopeFor: (...a) => mockScope(...a),
eventWhere: scope => (scope ? { OR: [{ campusId: { in: scope.campusIds } }, { campusId: null }] } : {}),
}));
const dashboard = require('../src/utils/dashboard');
beforeEach(() => jest.clearAllMocks());
test('an amount typed in the palette', () => {
expect(dashboard.amountOf('R450')).toBe(450);
expect(dashboard.amountOf('r 450,50')).toBe(450.5);
expect(dashboard.amountOf('450.5')).toBe(450.5);
expect(dashboard.amountOf('Camp 2026')).toBeNull();
expect(dashboard.amountOf('')).toBeNull();
});
describe('what is waiting', () => {
test('admins see everything they can act on', async () => {
const items = await dashboard.pendingItems({ user: { role: 'admin' } }, null);
expect(items.map(i => i.key)).toEqual(['privacy', 'unpaid', 'cover', 'roster', 'waitlist', 'reconcile', 'cashup', 'webhooks']);
// Only roles still short of people count.
expect(items.find(i => i.key === 'roster').count).toBe(1);
expect(items.find(i => i.key === 'unpaid').href).toBe('/dashboard/admin/registrations?status=pending');
});
test('a campus-limited supervisor: no cashups, provider payments, privacy or webhooks; only their events', async () => {
const scope = { campusIds: ['north'] };
const items = await dashboard.pendingItems({ user: { role: 'supervisor' } }, scope);
expect(items.map(i => i.key)).toEqual(['unpaid', 'cover', 'roster', 'waitlist']);
expect(items.find(i => i.key === 'unpaid').href).toBe('/dashboard/supervisor/reports?report=regStatus');
const where = mockDb.registration.count.mock.calls[0][0].where;
expect(where.event.AND[0]).toEqual({ OR: [{ campusId: { in: ['north'] } }, { campusId: null }] });
});
test('items with nothing waiting are left out', async () => {
count.mockResolvedValue(0);
mockDb.servingRole.findMany.mockResolvedValueOnce([]);
expect(await dashboard.pendingItems({ user: { role: 'admin' } }, null)).toEqual([]);
count.mockResolvedValue(2);
});
});
describe('palette search', () => {
test('too short a query finds nothing', async () => {
expect(await dashboard.search({ user: { role: 'admin' } }, 'a')).toEqual([]);
});
test('staff get events only, linked to the public page', async () => {
mockScope.mockResolvedValue(null);
mockDb.event.findMany.mockResolvedValueOnce([{ id: 'e1', title: 'Camp 2026', startDate: new Date('2026-03-01'), isActive: true, campus: null }]);
const out = await dashboard.search({ user: { role: 'staff' } }, 'camp');
expect(out).toEqual([expect.objectContaining({ type: 'event', href: '/events/e1' })]);
expect(mockDb.user.findMany).not.toHaveBeenCalled();
expect(mockDb.payment.findMany).not.toHaveBeenCalled();
});
test('supervisors find payments by amount; admins also people', async () => {
mockScope.mockResolvedValue(null);
mockDb.payment.findMany.mockResolvedValueOnce([{ id: 'p1', amount: 450, method: 'card', createdAt: new Date(), registrationId: 'r1', user: { name: 'Thandi' }, event: { title: 'Camp' } }]);
const sup = await dashboard.search({ user: { role: 'supervisor' } }, 'R450');
expect(sup.find(r => r.type === 'payment')).toMatchObject({ title: 'R450.00 — Thandi', href: '/dashboard/supervisor/payments' });
expect(mockDb.payment.findMany.mock.calls[0][0].where.amount).toEqual({ in: [450, -450] });
expect(mockDb.user.findMany).not.toHaveBeenCalled();
mockDb.user.findMany.mockResolvedValueOnce([{ id: 'u1', name: 'Thandi Test', email: 't@test.local', role: 'user' }]);
const adm = await dashboard.search({ user: { role: 'admin' } }, 'thandi');
expect(adm.find(r => r.type === 'person')).toMatchObject({ href: '/dashboard/admin/users?q=t%40test.local' });
});
});
+16 -3
View File
@@ -1,6 +1,6 @@
# Handover — where things stand and how to carry on
Last updated: 2026-10-28, after **1.48.2** (and Hub 1.5.0). The plan itself is in
Last updated: 2026-10-28, after **1.49.0** (and Hub 1.5.0). The plan itself is in
[ROADMAP-v2.md](./ROADMAP-v2.md) (decisions table at the end); the previous round of
features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
@@ -14,6 +14,7 @@ encrypted settings unreadable or break existing installs.
| Version | What |
|---------|------|
| 1.49.0 | **Dashboard and command palette** (§9 part 1): Today and Waiting for you on the staff/supervisor/admin dashboards (`GET /api/dashboard/today`), Ctrl+K palette (`GET /api/dashboard/search`, pages in `lib/commandPages.ts`), deep links on events/registrations/users |
| 1.48.2 | Four demo seeds (`npm run demo:small|events|ministry|multicampus`, `demo:list`); fixes `set-demo`, broken since 1.43.0 by TRUNCATE CASCADE through `User.homeCampusId` |
| 1.48.1 | Built-in privacy notice updated (rosters, series, season passes, webhooks, acceptance); editor shows built-in changes to sites with their own version; activation asks for agreement; never-accepted users asked once after login |
| 1.48.0 | **Outbound webhooks** (§8): Site settings → Webhooks, 8 event types, signed deliveries with retries and a delivery log, operator register entry per endpoint. Migration `20261028160000_webhooks` |
@@ -55,8 +56,8 @@ encrypted settings unreadable or break existing installs.
Hub 1.1.0 is deployed with update requests rolled out, and `scripts/install.sh` has been tried on
a test server (both done 2026-10-28).
1. **1.49.0 — Admin dashboard, command palette (Ctrl+K), bulk actions** (§9), then 1.50.0 Afrikaans
and 1.51.0 accessibility.
1. **1.49.1 — bulk actions** on registration/attendee lists (message, export, mark paid, move option,
cancel, allocate), **1.49.2 — saved filters** (§9), then 1.50.0 Afrikaans and 1.51.0 accessibility.
## Working on this repo
@@ -377,6 +378,18 @@ a test server (both done 2026-10-28).
report, season pass, kids board, roster, attendance registers, certificate check, cashups, campus
scoping).
## Where the dashboard and palette live (1.49.0)
- `backend/src/utils/dashboard.js` — `todaysEvents`, `moneyIn`, `pendingItems` (each item counted
separately, failures logged and skipped; role-only items for admins), `search` (+ `amountOf`).
Everything goes through `eventWhere(scopeFor(req))`. Routes: `routes/dashboardRoutes.js`.
**A new "something needs doing" count** goes in `pendingItems` (with its link and the `order`).
- Frontend: `components/dashboard/TodayPanel.tsx`, `components/shared/CommandPalette.tsx` (mounted in
`app/dashboard/layout.tsx`; `CommandPaletteButton` on the dashboards), `lib/commandPages.ts` —
**a new page or setting tab should be added there** with its roles.
- Deep links read `window.location.search` once on load (no Suspense needed).
- Tests: `tests/dashboard.test.js`, e2e `dashboard.spec.ts`.
## Known gaps / follow-ups
- WhatsApp STOP only works once someone pastes the webhook URL from Site settings → WhatsApp
+6 -3
View File
@@ -35,8 +35,8 @@ up by one. 1.45.0 **kids check-in** shipped (fully digital: pickup codes, sticke
ratios, offline desk). 1.46.0 **volunteer rosters** shipped (roles per event or session, sign-ups and
assigning, swaps on accept, QR self check-in, kids-room roles on duty, optional volunteer ticket).
Event series and cloning ships as 1.47.0–1.47.3 (2026-10-28); 1.47.0 **copy an event** and 1.47.1 **event series** (series page, Next in this series, follow) 1.47.2 **year-on-year
report** and 1.47.3 **season passes** shipped — §6 is done. 1.48.0 **outbound webhooks** shipped. **Next: 1.49.0 Admin dashboard, command
palette and bulk actions.**
report** and 1.47.3 **season passes** shipped — §6 is done. 1.48.0 **outbound webhooks** shipped. 1.49.0 **dashboard (Today, Waiting for you) and Ctrl+K palette**
shipped. **Next: 1.49.1 bulk actions, then 1.49.2 saved filters.**
**Repositories:**
@@ -517,6 +517,9 @@ register; deliveries kept 30 days.)
## 9. Admin dashboard, command palette, bulk actions
Three releases (2026-10-28): **1.49.0** dashboard (Today, Waiting for you) and the Ctrl+K command
palette ✅; **1.49.1** bulk actions; **1.49.2** saved filters.
- **Dashboard** as supervisors' home: today's events/sessions, check-ins so
far, money in (today/week), pending items (unreconciled payments, consent
outstanding, waitlist offers, data requests), alerts.
@@ -968,7 +971,7 @@ income justify the hardware:
| 7 | 1.43.0 ✅ – 1.44.0 ✅ | **Multi-campus**: 1.43.0 campuses (settings, events, public filter and landing pages, home campus, filters, messages, summaries); 1.44.0 campus-scoped staff | Kids rooms and volunteer roles are per campus, so campuses come first; scoping is split out because it's security-heavy |
| 8 | 1.45.0 ✅ – 1.46.0 ✅ | Kids check-in, Volunteer rosters | Depend on sessions and campuses |
| 9 | 1.47.0 ✅ – 1.47.3 ✅, 1.48.0 ✅ | Event series & cloning (1.47.0 clone, 1.47.1 series, 1.47.2 year-on-year reports, 1.47.3 season pass), Outbound webhooks (Wallet passes removed from scope) | Smaller, independent wins |
| 10 | 1.49.0 – 1.51.0 | Admin dashboard/Ctrl+K/bulk actions, Afrikaans, Accessibility pass | Polish before the big release |
| 10 | 1.49.0 ✅ – 1.49.2, 1.50.0 – 1.51.0 | Admin dashboard/Ctrl+K/bulk actions, Afrikaans, Accessibility pass | Polish before the big release |
| 11 | 1.52.0 | TypeScript backend + OpenAPI + `/api/v2` alongside v1 | The breaking-change groundwork |
| 12 | **2.0.0** | **AI helpers** (§15 — headline feature: draft an event, summarise reports and feedback, draft messages, improve text; through the Hub with a rand cap per licence), v1 API removed, migration guide, marketing site/docs for selling | The release. AI needs the Hub (phase 6) for the key, metering and caps |
+49
View File
@@ -0,0 +1,49 @@
import { test, expect, loginAs } from "./fixtures";
// Dashboard "Today" and "Waiting for you", and the Ctrl+K command palette (1.49.0).
test.describe("dashboard and command palette", () => {
test("the admin dashboard shows what's on today and what's waiting", async ({ page }) => {
await loginAs(page, "admin");
await page.goto("/dashboard/admin");
const today = page.getByTestId("today-panel");
await expect(today).toBeVisible();
// Seeded on today: the hidden kids church and serving Sunday.
await expect(today.getByTestId("today-events")).toContainText("E2E Serving Sunday");
await expect(today.getByText("Waiting for you")).toBeVisible();
});
test("Ctrl+K finds an event and opens it", async ({ page }) => {
await loginAs(page, "supervisor");
await page.goto("/dashboard/supervisor");
await expect(page.getByTestId("today-panel")).toBeVisible();
await page.keyboard.press("Control+k");
const dialog = page.getByRole("dialog", { name: "Go to or search" });
await expect(dialog).toBeVisible();
await dialog.getByRole("combobox", { name: "Search" }).fill("Paid Conference");
await expect(dialog.getByRole("option", { name: /E2E Paid Conference/ })).toBeVisible();
await page.keyboard.press("Enter");
await expect(page).toHaveURL(/\/dashboard\/supervisor\/events/);
await expect(page.getByRole("heading", { name: "Edit Event" })).toBeVisible();
await expect(page.getByPlaceholder("Event title")).toHaveValue("E2E Paid Conference");
});
test("the palette's actions and pages follow the role", async ({ page }) => {
await loginAs(page, "supervisor");
await page.goto("/dashboard/supervisor");
await page.getByRole("button", { name: /Search or jump to/ }).click();
const dialog = page.getByRole("dialog", { name: "Go to or search" });
await dialog.getByRole("combobox", { name: "Search" }).fill("new event");
await dialog.getByRole("option", { name: "New event" }).click();
await expect(page.getByRole("heading", { name: "New Event" })).toBeVisible();
// Staff don't get admin pages.
await loginAs(page, "staff");
await page.goto("/dashboard/staff");
await page.keyboard.press("Control+k");
const staffDialog = page.getByRole("dialog", { name: "Go to or search" });
await staffDialog.getByRole("combobox", { name: "Search" }).fill("users");
await expect(staffDialog.getByRole("option", { name: "Users" })).toHaveCount(0);
await staffDialog.getByRole("combobox", { name: "Search" }).fill("scan");
await expect(staffDialog.getByRole("option", { name: "Scan tickets" })).toBeVisible();
});
});
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events-frontend",
"version": "1.48.2",
"version": "1.49.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events-frontend",
"version": "1.48.2",
"version": "1.49.0",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@hookform/resolvers": "^5.2.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events-frontend",
"version": "1.48.2",
"version": "1.49.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
+13 -3
View File
@@ -17,6 +17,8 @@ import { AreaTrendChart } from "@/components/charts/AreaTrendChart";
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table";
import type { DashboardStats, PaymentStats } from "@/types";
import { CampusFilter } from "@/components/shared/CampusFilter";
import { TodayPanel } from "@/components/dashboard/TodayPanel";
import { CommandPaletteButton } from "@/components/shared/CommandPalette";
const formatRand = (n: number) => `R ${(n || 0).toFixed(2)}`;
const formatRandAxis = (n: number) => `R${new Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 }).format(n)}`;
@@ -124,11 +126,19 @@ export default function AdminDashboardPage() {
return (
<div className="max-w-6xl mx-auto w-full overflow-x-hidden">
<div className="mb-6">
<h1 className="text-2xl font-semibold text-gray-900">Welcome back{user ? `, ${user.name}` : ""} 👋</h1>
<p className="text-sm text-gray-500 mt-1">Here&apos;s what&apos;s happening with your events today.</p>
<CampusFilter value={campus} onChange={setCampus} everyLabel="All campuses (church-wide total)" className="mt-3" id="dashboard-campus" mine />
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold text-gray-900">Welcome back{user ? `, ${user.name}` : ""} 👋</h1>
<p className="text-sm text-gray-500 mt-1">Here&apos;s what&apos;s happening with your events today.</p>
</div>
<CommandPaletteButton />
</div>
</div>
<TodayPanel supervisor />
<CampusFilter value={campus} onChange={setCampus} everyLabel="All campuses (church-wide total)" className="mb-3" id="dashboard-campus" mine />
{!isAdmin && (
<div className="p-3 border rounded bg-yellow-50 text-yellow-800 text-sm mb-4">
You need admin access to use these tools.
@@ -54,6 +54,16 @@ export default function AdminRegistrationsPage() {
const [includePastEvents, setIncludePastEvents] = useState(false);
const [includeInactiveEvents, setIncludeInactiveEvents] = useState(false);
// Deep links (the dashboard's "Waiting for you" list and the Ctrl+K palette): ?q=, ?status=, ?event=.
// A search by reference may be for a past event's registration, so past events are included then.
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const q = params.get("q");
if (q) { setQuery(q); setIncludePastEvents(true); }
if (params.get("status")) setStatusFilter(params.get("status") || "");
if (params.get("event")) setEventFilter(params.get("event") || "");
}, []);
// Expanded rows + form responses cache
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [formResponses, setFormResponses] = useState<Record<string, FormResponse[]>>({});
@@ -58,6 +58,12 @@ export default function AdminUsersPage() {
const [roleFilter, setRoleFilter] = useState<Role | "">("");
const [activeFilter, setActiveFilter] = useState<"" | "true" | "false">("");
// ?q= (the Ctrl+K palette links here with a person's email).
useEffect(() => {
const q = new URLSearchParams(window.location.search).get("q");
if (q) { setQuery(q); setDebouncedQuery(q); }
}, []);
// Debounce search
useEffect(() => {
const t = setTimeout(() => setDebouncedQuery(query), 350);
+3
View File
@@ -9,6 +9,7 @@ import { Sidebar, MobileSidebar } from "@/components/layout/Sidebar";
import { PrivacyPrompts } from "@/components/shared/PrivacyPrompts";
import { LicenceBanner } from "@/components/shared/LicenceBanner";
import { ProviderNotices } from "@/components/shared/ProviderNotices";
import { CommandPalette } from "@/components/shared/CommandPalette";
// The sidebar shows only on these exact routes — each role's dashboard root,
// plus Profile & Security and Site Settings (matching the redesign
@@ -92,6 +93,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
<LicenceBanner />
<ProviderNotices />
<PrivacyPrompts />
<CommandPalette />
<MobileSidebar />
<div className="flex-1 flex min-h-0">
<Sidebar />
@@ -110,6 +112,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
<LicenceBanner />
<ProviderNotices />
<PrivacyPrompts />
<CommandPalette />
<main className="flex-1 p-6 bg-gray-50">{children}</main>
<Footer />
</div>
+11 -2
View File
@@ -11,6 +11,8 @@ import { StatCard, StatCardRow } from "@/components/shared/StatCard";
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
import type { DashboardStats, RecentScan, ScanStats } from "@/types";
import { CampusScopeNote } from "@/components/shared/CampusFilter";
import { TodayPanel } from "@/components/dashboard/TodayPanel";
import { CommandPaletteButton } from "@/components/shared/CommandPalette";
const QUICK_ACTIONS = [
{ href: "/dashboard/staff/ticket-scanning", label: "Scan tickets", description: "Use your device camera to validate tickets", icon: QrCode },
@@ -76,11 +78,18 @@ export default function StaffDashboardPage() {
return (
<div className="max-w-6xl mx-auto w-full">
<div className="mb-6">
<h1 className="text-2xl font-semibold text-gray-900">Welcome back{user ? `, ${user.name}` : ""} 👋</h1>
<p className="text-sm text-gray-500 mt-1">Here&apos;s today&apos;s scanning activity.</p>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold text-gray-900">Welcome back{user ? `, ${user.name}` : ""} 👋</h1>
<p className="text-sm text-gray-500 mt-1">Here&apos;s today&apos;s scanning activity.</p>
</div>
<CommandPaletteButton />
</div>
<CampusScopeNote className="mt-2" />
</div>
<TodayPanel supervisor={false} />
{!canView && (
<div className="p-3 border rounded bg-yellow-50 text-yellow-800 text-sm mb-4">
You need staff, supervisor, or admin access to use staff tools.
@@ -2113,6 +2113,19 @@ export default function ManageEventsPage() {
};
const closeModal = () => { setModalMode(null); setEditingEvent(null); setCopyFrom(null); };
// Deep links (the Ctrl+K palette): ?edit=<eventId> opens that event, ?new=1 a new one.
const deepLinked = useRef(false);
useEffect(() => {
if (deepLinked.current || !token) return;
deepLinked.current = true;
const params = new URLSearchParams(window.location.search);
const edit = params.get("edit");
if (edit) openEdit({ id: edit } as Event);
else if (params.get("new") === "1") openCreate();
if (edit || params.get("new")) window.history.replaceState(null, "", window.location.pathname);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
return (
<div className="max-w-4xl mx-auto w-full p-6">
<div className="flex items-center justify-between mb-6 flex-wrap gap-3">
+13 -3
View File
@@ -17,6 +17,8 @@ import { AreaTrendChart } from "@/components/charts/AreaTrendChart";
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table";
import type { DashboardStats, PaymentStats, RecentScan, ScanStats } from "@/types";
import { CampusFilter, CampusScopeNote } from "@/components/shared/CampusFilter";
import { TodayPanel } from "@/components/dashboard/TodayPanel";
import { CommandPaletteButton } from "@/components/shared/CommandPalette";
const formatRand = (n: number) => `R ${(n || 0).toFixed(2)}`;
const formatRandAxis = (n: number) => `R${new Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 }).format(n)}`;
@@ -120,12 +122,20 @@ export default function SupervisorDashboardPage() {
return (
<div className="max-w-6xl mx-auto w-full overflow-x-hidden">
<div className="mb-6">
<h1 className="text-2xl font-semibold text-gray-900">Welcome back{user ? `, ${user.name}` : ""} 👋</h1>
<p className="text-sm text-gray-500 mt-1">Here&apos;s what&apos;s happening with your events today.</p>
<CampusFilter value={campus} onChange={setCampus} everyLabel="All campuses (church-wide total)" className="mt-3" id="dashboard-campus" mine />
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold text-gray-900">Welcome back{user ? `, ${user.name}` : ""} 👋</h1>
<p className="text-sm text-gray-500 mt-1">Here&apos;s what&apos;s happening with your events today.</p>
</div>
<CommandPaletteButton />
</div>
<CampusScopeNote className="mt-2" />
</div>
<TodayPanel supervisor />
<CampusFilter value={campus} onChange={setCampus} everyLabel="All campuses (church-wide total)" className="mb-3" id="dashboard-campus" mine />
{!canView && (
<div className="p-3 border rounded bg-yellow-50 text-yellow-800 text-sm mb-4">
You need supervisor or admin access to use these tools.
@@ -0,0 +1,122 @@
"use client";
import React, { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { CalendarClock, ArrowRight, Baby, HandHeart, UserCheck, Banknote, Inbox, CheckCircle2 } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { useVisiblePolling } from "@/hooks/useVisiblePolling";
import { CampusBadge } from "@/components/shared/CampusFilter";
import type { Event } from "@/types";
type TodayEvent = {
id: string; title: string; location: string | null; campus: Event["campus"]; isHidden: boolean; kidsCheckIn: boolean;
startsAt: string; endsAt: string; sessionLabel: string | null;
places: number; checkedIn: number; kidsIn: number | null;
volunteers: { needed: number; rostered: number; checkedIn: number } | null;
};
type Pending = { key: string; label: string; count: number; href: string; tone: string };
type Today = { events: TodayEvent[]; money: { today: number; todayCount: number; week: number } | null; pending: Pending[] };
const TONE: Record<string, string> = {
amber: "bg-amber-50 text-amber-800", rose: "bg-rose-50 text-rose-700", blue: "bg-blue-50 text-blue-700",
violet: "bg-violet-50 text-violet-700", green: "bg-green-50 text-green-700",
};
const time = (d: string) => new Date(d).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
const rand = (n: number) => `R ${n.toFixed(2)}`;
/**
* The top of every staff dashboard (1.49.0): what's on today and how it's going, and — for
* supervisors and admins — money in and what's waiting for someone, each linking to where it's
* dealt with. Refreshes every 30 seconds while the tab is visible.
*/
export function TodayPanel({ supervisor }: { supervisor: boolean }) {
const { token } = useAuth();
const [data, setData] = useState<Today | null>(null);
const load = useCallback(async () => {
if (!token) return;
try { setData(await apiFetch<Today>("/api/dashboard/today", { authToken: token })); } catch { /* keep what's shown */ }
}, [token]);
useEffect(() => { load(); }, [load]);
useVisiblePolling(load, 30000, !!token);
if (!data) return null;
return (
<div className={`grid gap-6 mb-6 ${supervisor ? "lg:grid-cols-3" : ""}`} data-testid="today-panel">
<div className={`border rounded-xl p-4 bg-white shadow-sm min-w-0 ${supervisor ? "lg:col-span-2" : ""}`}>
<div className="flex items-center gap-2 mb-3">
<CalendarClock className="w-5 h-5 text-brand-600" />
<h2 className="text-lg font-semibold">Today</h2>
<span className="text-xs text-gray-500">{new Date().toLocaleDateString(undefined, { weekday: "long", day: "numeric", month: "long" })}</span>
</div>
{data.events.length === 0 ? (
<p className="text-sm text-gray-500">Nothing on today.</p>
) : (
<ul className="divide-y" data-testid="today-events">
{data.events.map(e => {
const pct = e.places ? Math.min(100, Math.round((e.checkedIn / e.places) * 100)) : 0;
return (
<li key={e.id} className="py-3 first:pt-0 last:pb-0">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<div className="font-medium text-gray-900 flex items-center gap-2 flex-wrap">
{e.title}{e.sessionLabel && <span className="text-xs font-normal text-gray-500">· {e.sessionLabel}</span>}
<CampusBadge campus={e.campus} />
</div>
<div className="text-xs text-gray-500">{time(e.startsAt)}–{time(e.endsAt)}{e.location ? ` · ${e.location}` : ""}</div>
</div>
<Link href={e.kidsCheckIn ? "/dashboard/staff/kids" : `/dashboard/staff/ticket-scanning`} className="inline-flex items-center gap-1 text-xs text-brand-700 hover:underline shrink-0">
{e.kidsCheckIn ? "Kids desk" : "Scanner"} <ArrowRight className="w-3 h-3" />
</Link>
</div>
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-gray-600">
{!e.kidsCheckIn && (
<span className="inline-flex items-center gap-1.5">
<UserCheck className="w-3.5 h-3.5" /> {e.checkedIn} of {e.places} checked in
<span className="inline-block w-20 h-1.5 rounded bg-gray-100 overflow-hidden"><span className="block h-full bg-brand-500" style={{ width: `${pct}%` }} /></span>
</span>
)}
{e.kidsIn !== null && <span className="inline-flex items-center gap-1"><Baby className="w-3.5 h-3.5" /> {e.kidsIn} children in now</span>}
{e.volunteers && (
<span className="inline-flex items-center gap-1"><HandHeart className="w-3.5 h-3.5" /> {e.volunteers.checkedIn} of {e.volunteers.rostered} volunteers here{e.volunteers.rostered < e.volunteers.needed ? ` (${e.volunteers.needed - e.volunteers.rostered} short)` : ""}</span>
)}
</div>
</li>
);
})}
</ul>
)}
{data.money && (
<div className="mt-4 pt-3 border-t flex flex-wrap gap-x-6 gap-y-1 text-sm">
<span className="inline-flex items-center gap-1.5"><Banknote className="w-4 h-4 text-green-600" /> Today <strong>{rand(data.money.today)}</strong> <span className="text-xs text-gray-500">({data.money.todayCount} payment{data.money.todayCount === 1 ? "" : "s"})</span></span>
<span>Last 7 days <strong>{rand(data.money.week)}</strong></span>
</div>
)}
</div>
{supervisor && (
<div className="border rounded-xl p-4 bg-white shadow-sm min-w-0">
<div className="flex items-center gap-2 mb-3">
<Inbox className="w-5 h-5 text-brand-600" />
<h2 className="text-lg font-semibold">Waiting for you</h2>
</div>
{data.pending.length === 0 ? (
<p className="text-sm text-gray-500 inline-flex items-center gap-1.5"><CheckCircle2 className="w-4 h-4 text-green-600" /> All caught up.</p>
) : (
<ul className="space-y-2" data-testid="waiting">
{data.pending.map(p => (
<li key={p.key}>
<Link href={p.href} className="flex items-center gap-3 rounded-lg px-2 py-1.5 hover:bg-gray-50">
<span className={`min-w-[2rem] text-center text-sm font-semibold rounded-md px-1.5 py-0.5 ${TONE[p.tone] || "bg-gray-100 text-gray-700"}`}>{p.count}</span>
<span className="text-sm text-gray-700 flex-1">{p.label}</span>
<ArrowRight className="w-3.5 h-3.5 text-gray-400" />
</Link>
</li>
))}
</ul>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,150 @@
"use client";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { Search, Calendar, User, Banknote, ClipboardList, CornerDownLeft, Zap, FileText, Settings, BarChart2 } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { matchPages, type CommandPage } from "@/lib/commandPages";
type Result = { type: "event" | "payment" | "person" | "registration"; id: string; title: string; subtitle: string; href: string };
type Item = { key: string; group: string; title: string; subtitle?: string; href: string; icon: React.ElementType };
const RESULT_GROUP: Record<Result["type"], [string, React.ElementType]> = {
event: ["Events", Calendar], payment: ["Payments", Banknote], person: ["People", User], registration: ["Registrations", ClipboardList],
};
const PAGE_ICON: Record<CommandPage["group"], React.ElementType> = { Actions: Zap, Pages: FileText, Settings, Reports: BarChart2 };
/** Opens with Ctrl+K / ⌘K (or the event below) for staff, supervisors and admins. */
export const OPEN_PALETTE_EVENT = "cross-events:open-palette";
/**
* Ctrl+K command palette (1.49.0): jump to a page, setting or report, start an action ("New
* event"), or find an event, a payment by amount ("R450"), a person or a registration — what the
* person may see. ↑/↓ to move, Enter to open, Esc to close.
*/
export function CommandPalette() {
const { user, token } = useAuth();
const router = useRouter();
const role = user?.role || "user";
const allowed = role === "staff" || role === "supervisor" || role === "admin";
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [results, setResults] = useState<Result[]>([]);
const [active, setActive] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLUListElement>(null);
useEffect(() => {
if (!allowed) return;
const onKey = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
setOpen(o => !o);
}
};
const onOpen = () => setOpen(true);
window.addEventListener("keydown", onKey);
window.addEventListener(OPEN_PALETTE_EVENT, onOpen);
return () => { window.removeEventListener("keydown", onKey); window.removeEventListener(OPEN_PALETTE_EVENT, onOpen); };
}, [allowed]);
useEffect(() => {
if (open) { setQuery(""); setResults([]); setActive(0); setTimeout(() => inputRef.current?.focus(), 0); }
}, [open]);
// Search as they type (after a short pause); pages are matched here, instantly.
useEffect(() => {
if (!open || !token) return;
const q = query.trim();
if (q.length < 2) { setResults([]); return; }
const timer = setTimeout(() => {
apiFetch<{ results: Result[] }>(`/api/dashboard/search?q=${encodeURIComponent(q)}`, { authToken: token })
.then(r => setResults(r.results || []))
.catch(() => setResults([]));
}, 200);
return () => clearTimeout(timer);
}, [query, open, token]);
const items = useMemo<Item[]>(() => {
const pages = matchPages(role, query).slice(0, query.trim() ? 8 : 10).map(p => ({ key: `page:${p.href}`, group: p.group, title: p.label, href: p.href, icon: PAGE_ICON[p.group] }));
const found = results.map(r => ({ key: `${r.type}:${r.id}`, group: RESULT_GROUP[r.type][0], title: r.title, subtitle: r.subtitle, href: r.href, icon: RESULT_GROUP[r.type][1] }));
return query.trim() ? [...found, ...pages] : pages;
}, [role, query, results]);
useEffect(() => { setActive(a => Math.min(a, Math.max(0, items.length - 1))); }, [items.length]);
useEffect(() => { listRef.current?.querySelector(`[data-index="${active}"]`)?.scrollIntoView({ block: "nearest" }); }, [active]);
const go = useCallback((item: Item | undefined) => {
if (!item) return;
setOpen(false);
router.push(item.href);
}, [router]);
if (!allowed || !open) return null;
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") { e.preventDefault(); setOpen(false); }
else if (e.key === "ArrowDown") { e.preventDefault(); setActive(a => Math.min(items.length - 1, a + 1)); }
else if (e.key === "ArrowUp") { e.preventDefault(); setActive(a => Math.max(0, a - 1)); }
else if (e.key === "Enter") { e.preventDefault(); go(items[active]); }
};
let lastGroup = "";
return (
<div className="fixed inset-0 z-[60] flex items-start justify-center bg-black/40 p-4 pt-[12vh]" onMouseDown={() => setOpen(false)}>
<div role="dialog" aria-modal="true" aria-label="Go to or search" className="w-full max-w-xl rounded-xl bg-white shadow-2xl overflow-hidden" onMouseDown={e => e.stopPropagation()}>
<div className="flex items-center gap-2 border-b px-3">
<Search className="w-4 h-4 text-gray-400 shrink-0" />
<input
ref={inputRef} value={query} onChange={e => { setQuery(e.target.value); setActive(0); }} onKeyDown={onKeyDown}
placeholder={role === "admin" ? "Go to a page, or find an event, person, payment (R450)…" : role === "supervisor" ? "Go to a page, or find an event or payment (R450)…" : "Go to a page or find an event…"}
className="flex-1 py-3 text-sm outline-none bg-transparent" aria-label="Search" role="combobox" aria-expanded="true" aria-controls="palette-results"
aria-activedescendant={items[active] ? `palette-item-${active}` : undefined}
/>
<kbd className="text-[10px] text-gray-400 border rounded px-1.5 py-0.5">Esc</kbd>
</div>
<ul id="palette-results" ref={listRef} role="listbox" className="max-h-[55vh] overflow-y-auto py-1" data-testid="palette-results">
{items.length === 0 && <li className="px-4 py-6 text-sm text-gray-500 text-center">{query.trim().length < 2 ? "Type to search." : "Nothing found."}</li>}
{items.map((item, i) => {
const header = item.group !== lastGroup ? item.group : null;
lastGroup = item.group;
const Icon = item.icon;
return (
<React.Fragment key={item.key}>
{header && <li className="px-4 pt-2 pb-1 text-[11px] font-semibold uppercase tracking-wide text-gray-400" role="presentation">{header}</li>}
<li
id={`palette-item-${i}`} data-index={i} role="option" aria-selected={i === active}
className={`mx-1 flex items-center gap-3 rounded-lg px-3 py-2 cursor-pointer ${i === active ? "bg-brand-50" : "hover:bg-gray-50"}`}
onMouseMove={() => setActive(i)} onClick={() => go(item)}
>
<Icon className="w-4 h-4 text-gray-500 shrink-0" />
<span className="min-w-0 flex-1">
<span className="block text-sm text-gray-900 truncate">{item.title}</span>
{item.subtitle && <span className="block text-xs text-gray-500 truncate">{item.subtitle}</span>}
</span>
{i === active && <CornerDownLeft className="w-3.5 h-3.5 text-gray-400" />}
</li>
</React.Fragment>
);
})}
</ul>
<div className="border-t px-4 py-2 text-[11px] text-gray-400 flex gap-4">
<span>↑↓ to move</span><span>Enter to open</span><span>Ctrl K to open or close</span>
</div>
</div>
</div>
);
}
/** A button that opens the palette (for people who don't use the keyboard shortcut). */
export function CommandPaletteButton({ className = "" }: { className?: string }) {
const { user } = useAuth();
if (!user || !["staff", "supervisor", "admin"].includes(user.role || "")) return null;
return (
<button type="button" onClick={() => window.dispatchEvent(new Event(OPEN_PALETTE_EVENT))}
className={`inline-flex items-center gap-2 rounded-lg border bg-white px-3 py-1.5 text-sm text-gray-500 hover:bg-gray-50 ${className}`}>
<Search className="w-4 h-4" /> Search or jump to… <kbd className="ml-2 text-[10px] border rounded px-1.5 py-0.5">Ctrl K</kbd>
</button>
);
}
@@ -1,5 +1,5 @@
import React from "react";
import { TrendingUp, Zap, Shield, Users, Calendar, BarChart2, Settings } from "lucide-react";
import { TrendingUp, Zap, Shield, Users, Calendar, BarChart2, Settings, CalendarClock, Search } from "lucide-react";
import { GuideItem } from "@/components/shared/GuideItem";
import type { HelpContent } from "./types";
@@ -22,6 +22,12 @@ export const dashboardAdminHelpContent: HelpContent = {
<div className="space-y-4">
<p>The top row and the sections below it summarise your events at a glance — no need to open Reports for a quick check.</p>
<div className="space-y-4">
<GuideItem icon={CalendarClock} title="Today and Waiting for you" tone="blue">
What&apos;s on today — each event or session with how many people are checked in so far, children in at kids church, and volunteers here. Money in today and over the last week. &quot;Waiting for you&quot; lists what needs someone — unpaid registrations for the next two weeks, parental consent still outstanding, waitlists, cover requests and roster gaps (and for admins: cashups, payments to reconcile, privacy requests, failed webhooks); click one to deal with it. It refreshes every 30 seconds.
</GuideItem>
<GuideItem icon={Search} title="Search or jump to — Ctrl K" tone="violet">
Press Ctrl K (⌘K on a Mac) anywhere in the dashboard, or use the button at the top: type a page, setting or report and press Enter, start an action like &quot;New event&quot;, or find an event or a payment by amount (&quot;R450&quot;) — admins also find people and registrations.
</GuideItem>
<GuideItem icon={TrendingUp} title="Past month's KPIs" tone="brand">
Active events, revenue, donations, registrations, and tickets sold — each covers a rolling 30-ish days (today back one month), compared against the equivalent month before that, for an apples-to-apples comparison.
</GuideItem>
@@ -1,5 +1,5 @@
import React from "react";
import { QrCode, Ticket, Lock } from "lucide-react";
import { QrCode, Ticket, Lock, CalendarClock, Search } from "lucide-react";
import { GuideItem } from "@/components/shared/GuideItem";
import type { HelpContent } from "./types";
@@ -20,6 +20,12 @@ export const dashboardStaffHelpContent: HelpContent = {
<div className="space-y-4">
<p>Use Scan tickets to validate attendees at the door with your device&apos;s camera, and Event tickets & printing to browse an event&apos;s tickets or print a list.</p>
<div className="space-y-4">
<GuideItem icon={CalendarClock} title="Today" tone="blue">
What&apos;s on today — each event or session with how many people are checked in so far and children in at kids church. It refreshes every 30 seconds.
</GuideItem>
<GuideItem icon={Search} title="Search or jump to — Ctrl K" tone="violet">
Press Ctrl K (⌘K on a Mac) anywhere in the dashboard, or use the button at the top: type a page, setting or report and press Enter, start an action like &quot;New event&quot;, or find an event.
</GuideItem>
<GuideItem icon={QrCode} title="Scans today / My scans / Last hour" tone="brand">
Live counts of ticket scans — across everyone, just you, and the last hour.
</GuideItem>
@@ -1,5 +1,5 @@
import React from "react";
import { TrendingUp, Zap, QrCode, UserPlus, BarChart2, DoorOpen } from "lucide-react";
import { TrendingUp, Zap, QrCode, UserPlus, BarChart2, DoorOpen, CalendarClock, Search } from "lucide-react";
import { GuideItem } from "@/components/shared/GuideItem";
import type { HelpContent } from "./types";
@@ -21,6 +21,12 @@ export const dashboardSupervisorHelpContent: HelpContent = {
<div className="space-y-4">
<p>The top row and the sections below it summarise your events at a glance — no need to open Reports for a quick check.</p>
<div className="space-y-4">
<GuideItem icon={CalendarClock} title="Today and Waiting for you" tone="blue">
What&apos;s on today — each event or session with how many people are checked in so far, children in at kids church, and volunteers here. Money in today and over the last week. &quot;Waiting for you&quot; lists what needs someone — unpaid registrations for the next two weeks, parental consent still outstanding, waitlists, cover requests and roster gaps (and for admins: cashups, payments to reconcile, privacy requests, failed webhooks); click one to deal with it. It refreshes every 30 seconds.
</GuideItem>
<GuideItem icon={Search} title="Search or jump to — Ctrl K" tone="violet">
Press Ctrl K (⌘K on a Mac) anywhere in the dashboard, or use the button at the top: type a page, setting or report and press Enter, start an action like &quot;New event&quot;, or find an event or a payment by amount (&quot;R450&quot;) — admins also find people and registrations.
</GuideItem>
<GuideItem icon={TrendingUp} title="Past month's KPIs" tone="brand">
Active events, revenue, donations, registrations, and tickets sold — each covers a rolling 30-ish days (today back one month), compared against the equivalent month before that, for an apples-to-apples comparison.
</GuideItem>
+68
View File
@@ -0,0 +1,68 @@
// What the Ctrl+K command palette can jump to without searching (1.49.0): pages, settings tabs,
// reports and quick actions, each for the roles that can open it. `keywords` widen matching.
export type Role = "staff" | "supervisor" | "admin";
export type CommandPage = { label: string; href: string; group: "Actions" | "Pages" | "Settings" | "Reports"; roles: Role[]; keywords?: string };
const S: Role[] = ["staff", "supervisor", "admin"];
const SUP: Role[] = ["supervisor", "admin"];
const ADM: Role[] = ["admin"];
export const COMMAND_PAGES: CommandPage[] = [
// Actions
{ group: "Actions", label: "New event", href: "/dashboard/supervisor/events?new=1", roles: SUP, keywords: "create add event" },
{ group: "Actions", label: "Register someone (manual registration)", href: "/dashboard/supervisor/manual", roles: SUP, keywords: "add registration walk-in guest" },
{ group: "Actions", label: "Scan tickets", href: "/dashboard/staff/ticket-scanning", roles: S, keywords: "scanner check in qr door" },
{ group: "Actions", label: "Kids check-in desk", href: "/dashboard/staff/kids", roles: S, keywords: "children pickup code" },
{ group: "Actions", label: "Record a payment", href: "/dashboard/supervisor/payments", roles: SUP, keywords: "cash eft donation pay" },
{ group: "Actions", label: "Email attendees", href: "/dashboard/supervisor/email-attendees", roles: SUP, keywords: "message send mail" },
{ group: "Actions", label: "WhatsApp attendees", href: "/dashboard/supervisor/whatsapp-attendees", roles: SUP, keywords: "message send" },
// Pages
{ group: "Pages", label: "Events", href: "/dashboard/supervisor/events", roles: SUP, keywords: "manage edit copy" },
{ group: "Pages", label: "Event series", href: "/dashboard/supervisor/series", roles: SUP, keywords: "season pass follow" },
{ group: "Pages", label: "Registrations", href: "/dashboard/admin/registrations", roles: ADM, keywords: "orders cancel status" },
{ group: "Pages", label: "Users", href: "/dashboard/admin/users", roles: ADM, keywords: "people accounts roles" },
{ group: "Pages", label: "At the door", href: "/dashboard/supervisor/at-the-door", roles: SUP, keywords: "walk-ins print" },
{ group: "Pages", label: "Payments & donations", href: "/dashboard/supervisor/payments", roles: SUP, keywords: "reconcile yoco payfast" },
{ group: "Pages", label: "Cashup", href: "/dashboard/admin/cashup", roles: ADM, keywords: "close costs reconcile takings" },
{ group: "Pages", label: "Rooms & groups", href: "/dashboard/supervisor/allocations", roles: SUP, keywords: "allocations cabins buses" },
{ group: "Pages", label: "Attendance register", href: "/dashboard/supervisor/attendance", roles: S, keywords: "sessions certificates" },
{ group: "Pages", label: "Waitlist", href: "/dashboard/supervisor/waitlist", roles: S, keywords: "sold out offers" },
{ group: "Pages", label: "Parental consent", href: "/dashboard/supervisor/consent", roles: S, keywords: "youth medical" },
{ group: "Pages", label: "Volunteer rosters", href: "/dashboard/supervisor/volunteers", roles: SUP, keywords: "serving roles cover" },
{ group: "Pages", label: "Volunteers today", href: "/dashboard/staff/volunteers", roles: S, keywords: "serving check-in qr" },
{ group: "Pages", label: "Kids check-in setup", href: "/dashboard/supervisor/kids", roles: SUP, keywords: "rooms ratios" },
{ group: "Pages", label: "Kids room screen", href: "/dashboard/staff/kids/rooms", roles: S, keywords: "children leaders on duty" },
{ group: "Pages", label: "Coupon codes", href: "/dashboard/supervisor/coupons", roles: SUP, keywords: "discount" },
{ group: "Pages", label: "Bundle deals", href: "/dashboard/supervisor/bundles", roles: SUP, keywords: "family deal" },
{ group: "Pages", label: "Event tickets & printing", href: "/dashboard/staff/event-tickets", roles: S, keywords: "print list" },
{ group: "Pages", label: "Offline scanning", href: "/dashboard/supervisor/offline-scans", roles: SUP, keywords: "devices sync" },
{ group: "Pages", label: "Attendee forms", href: "/dashboard/admin/forms", roles: ADM, keywords: "answers" },
{ group: "Pages", label: "Audit log", href: "/dashboard/admin/audit-log", roles: ADM, keywords: "history who changed" },
{ group: "Pages", label: "Privacy (POPIA)", href: "/dashboard/admin/privacy", roles: ADM, keywords: "notice operators retention breaches readiness" },
{ group: "Pages", label: "Privacy requests", href: "/dashboard/admin/data-requests", roles: ADM, keywords: "delete download correction popia" },
{ group: "Pages", label: "My profile & security", href: "/dashboard/user/profile", roles: S, keywords: "password two-factor" },
// Settings
...([
["organisation", "Organisation"], ["campuses", "Campuses"], ["branding", "Branding"], ["notifications", "Notifications"],
["email", "Email"], ["legal", "Legal"], ["whatsapp", "WhatsApp"], ["payments", "Payments"], ["webhooks", "Webhooks"],
["backups", "Backups"], ["jobs", "Background jobs"], ["licence", "Licence & updates"],
] as const).map(([tab, label]): CommandPage => ({ group: "Settings", label: `Settings: ${label}`, href: `/dashboard/admin/settings?tab=${tab}`, roles: ADM, keywords: "site settings" })),
// Reports
...([
["payments", "Payments between dates"], ["revenue", "Revenue summary"], ["regStatus", "Registration status"],
["funnel", "Registration funnel"], ["feedbackSummary", "Feedback summary"], ["usage", "Ticket usage"],
["financeReport", "Finance report"], ["seriesComparison", "Series: year on year"], ["discounts", "Discounts"],
] as const).map(([key, label]): CommandPage => ({ group: "Reports", label: `Report: ${label}`, href: `/dashboard/supervisor/reports?report=${key}`, roles: SUP, keywords: "report" })),
];
/** Pages for a role, matching every word of the query (in the label or keywords). */
export function matchPages(role: string, query: string): CommandPage[] {
const r = (["staff", "supervisor", "admin"].includes(role) ? role : null) as Role | null;
if (!r) return [];
const words = query.toLowerCase().split(/\s+/).filter(Boolean);
return COMMAND_PAGES.filter(p => p.roles.includes(r)).filter(p => {
const hay = `${p.label} ${p.keywords || ""} ${p.group}`.toLowerCase();
return words.every(w => hay.includes(w));
});
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events",
"version": "1.48.2",
"version": "1.49.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events",
"version": "1.48.2",
"version": "1.49.0",
"license": "ISC",
"devDependencies": {
"@playwright/test": "^1.64.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events",
"version": "1.48.2",
"version": "1.49.0",
"main": "index.js",
"scripts": {
"dev:backend": "cd backend && npm run dev",