Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98173c1380 | ||
|
|
6297613d0d | ||
|
|
5efb4cd6dd | ||
|
|
e40803cbbc |
@@ -7,6 +7,19 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.49.2] - 2026-10-28
|
||||
|
||||
### Added
|
||||
|
||||
- **Saved filters** on Manage registrations, Users, Events and Reports: set the filters, then
|
||||
**Saved filters → Save** under a name; click one to apply it; **★** makes it the default, applied
|
||||
when the page opens (but not over a link that already says what to show, such as the
|
||||
dashboard's "Waiting for you"). Reports remember the report, events, dates, campus and the
|
||||
past/inactive/closed options. Per person, stored on the server (up to 30 per page), removed when
|
||||
an account is anonymised.
|
||||
- `/api/saved-filters` (list, save, rename/default, delete — your own only). Migration
|
||||
`20261028180000_saved_filters`.
|
||||
|
||||
## [1.49.1] - 2026-10-28
|
||||
|
||||
### Added
|
||||
|
||||
@@ -481,6 +481,9 @@ logout controls. Disconnected sessions are recovered automatically.
|
||||
(records the balance by method), move to another ticket (same tickets and QR
|
||||
codes, repriced), put in a room or group, export to Excel, cancel — each
|
||||
through the same rules as one at a time, with a result per registration.
|
||||
- **Saved filters** — on Manage registrations, Users, Events and Reports: name the
|
||||
current filters, apply one with a click, star one as the default for the page
|
||||
(not applied over a deep link). Per person, on the server.
|
||||
- **Cashup** — see below.
|
||||
- **Audit log** (`/dashboard/admin/audit-log`) — who did what and when for
|
||||
refunds, donation assign/unassign, manual registrations, staff
|
||||
|
||||
@@ -12,6 +12,12 @@ 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.2
|
||||
|
||||
- **Saved filters.** On Manage registrations, Users, Events and Reports, save the filters you use
|
||||
under a name and get back to them with one click. Star one and it's ready every time you open the
|
||||
page. They're yours and follow you to any device.
|
||||
|
||||
## 1.49.1
|
||||
|
||||
- **Do many at once.** On Manage registrations, tick the people you want (or select everyone
|
||||
|
||||
@@ -933,6 +933,18 @@ 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`.
|
||||
|
||||
## Saved filters
|
||||
|
||||
Per person, on list pages `registrations`, `users`, `events`, `reports` (staff+; your own only — someone
|
||||
else's id answers 404).
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/saved-filters?page=` | Mine for the page, default first |
|
||||
| POST | `/api/saved-filters` | `{ page, name, params, isDefault? }` — params: strings, numbers, booleans, lists of strings (≤ 4 KB); same name replaces; ≤ 30 per page |
|
||||
| PUT | `/api/saved-filters/:id` | `{ name?, isDefault? }` (one default per page) |
|
||||
| DELETE | `/api/saved-filters/:id` | Delete |
|
||||
|
||||
## Bulk actions on registrations
|
||||
|
||||
`POST /api/registrations/bulk` (supervisor+): `{ action, registrationIds: [...] (≤ 500), ...params }` →
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.49.1",
|
||||
"version": "1.49.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "event-management-backend",
|
||||
"version": "1.49.1",
|
||||
"version": "1.49.2",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@prisma/adapter-pg": "^7.10.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.49.1",
|
||||
"version": "1.49.2",
|
||||
"description": "Event Management System Backend",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "SavedFilter" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"page" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"params" JSONB NOT NULL,
|
||||
"isDefault" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "SavedFilter_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SavedFilter_userId_page_idx" ON "SavedFilter"("userId", "page");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "SavedFilter_userId_page_name_key" ON "SavedFilter"("userId", "page", "name");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SavedFilter" ADD CONSTRAINT "SavedFilter_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
@@ -164,6 +164,7 @@ model User {
|
||||
volunteersCheckedIn VolunteerSlot[] @relation("VolunteerSlotCheckedInBy")
|
||||
seriesFollows SeriesFollow[]
|
||||
seasonPassPurchases SeasonPassPurchase[]
|
||||
savedFilters SavedFilter[]
|
||||
swapsAsked VolunteerSwap[] @relation("VolunteerSwapFrom")
|
||||
swapsTaken VolunteerSwap[] @relation("VolunteerSwapTo")
|
||||
|
||||
@@ -2297,3 +2298,20 @@ model WebhookDelivery {
|
||||
@@index([userId])
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
/// A person's saved filters on a list page (1.49.2): `page` is the list (registrations, users,
|
||||
/// events, reports), `params` the filter values. At most one default per person and page.
|
||||
model SavedFilter {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
page String
|
||||
name String
|
||||
params Json
|
||||
isDefault Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, page, name])
|
||||
@@index([userId, page])
|
||||
}
|
||||
|
||||
@@ -189,6 +189,7 @@ 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/saved-filters', require('./routes/savedFilterRoutes'));
|
||||
app.use('/api/users', userRoutes);
|
||||
app.use('/api/events', eventRoutes);
|
||||
app.use('/api/registrations', registrationRoutes);
|
||||
@@ -1015,6 +1016,14 @@ 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: 'Saved filters', base: '/api/saved-filters', endpoints: [
|
||||
{ method:'GET', path:'/api/saved-filters?page=', auth:'staff+', desc:'My saved filters for a list page (registrations, users, events, reports), default first' },
|
||||
{ method:'POST', path:'/api/saved-filters', auth:'staff+', desc:'Save (or replace by name) a filter: { page, name, params, isDefault? }. Up to 30 per page; making one the default clears the old default',
|
||||
request:{ body:{ page:'registrations', name:'Camp — unpaid', params:{ event:'ev-uuid', status:'pending' }, isDefault:false } } },
|
||||
{ method:'PUT', path:'/api/saved-filters/:id', auth:'staff+', desc:'Rename it, or make it (or stop it being) my default — my own only' },
|
||||
{ method:'DELETE', path:'/api/saved-filters/:id', auth:'staff+', desc:'Delete one of my saved filters' },
|
||||
]},
|
||||
|
||||
{ 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 }] }' },
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { protect, staff } = require('../middleware/authMiddleware');
|
||||
const { campusIndependent } = require('../utils/campusScope');
|
||||
const filters = require('../utils/savedFilters');
|
||||
|
||||
const OWN = campusIndependent('a person\'s own saved filters (the lists they filter are campus-checked themselves)');
|
||||
const fail = (res, e) => res.status(e.statusCode || 400).json({ message: e.message });
|
||||
|
||||
// Saved filters on list pages (1.49.2) — always the signed-in person's own.
|
||||
router.get('/', protect, staff, OWN, async (req, res) => {
|
||||
try { res.json(await filters.list(req.user.id, req.query.page)); } catch (e) { fail(res, e); }
|
||||
});
|
||||
router.post('/', protect, staff, OWN, async (req, res) => {
|
||||
try { res.status(201).json(await filters.save(req.user.id, req.body || {})); } catch (e) { fail(res, e); }
|
||||
});
|
||||
router.put('/:id', protect, staff, OWN, async (req, res) => {
|
||||
try { res.json(await filters.update(req.user.id, req.params.id, req.body || {})); } catch (e) { fail(res, e); }
|
||||
});
|
||||
router.delete('/:id', protect, staff, OWN, async (req, res) => {
|
||||
try { await filters.remove(req.user.id, req.params.id); res.json({ ok: true }); } catch (e) { fail(res, e); }
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -467,6 +467,8 @@ async function anonymiseUser(userId, db = prisma) {
|
||||
await tx.seriesFollow.deleteMany({ where: { userId } });
|
||||
// Webhook deliveries about them (kept 30 days for the delivery log).
|
||||
await tx.webhookDelivery.deleteMany({ where: { userId } });
|
||||
// Their saved list filters (can hold search text, such as names).
|
||||
await tx.savedFilter.deleteMany({ where: { userId } });
|
||||
|
||||
return { attendees: attendeeIds.length, formAnswers: answers.count, providerRecords: txns.length + checkouts.length, familyDeleted, shiftsGivenUp };
|
||||
});
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Saved filters (1.49.2): a person names the filters they use on a list page and applies them
|
||||
* with one click; one per page can be their default (applied when the page opens). Stored per
|
||||
* person on the server, so they follow them to any device. Only their own are ever read or changed.
|
||||
*/
|
||||
|
||||
const prisma = require('../config/db');
|
||||
|
||||
// The list pages that have saved filters, and who can open them.
|
||||
const PAGES = { registrations: 'admin', users: 'admin', events: 'supervisor', reports: 'supervisor' };
|
||||
const MAX_PER_PAGE = 30;
|
||||
const MAX_PARAMS_BYTES = 4000;
|
||||
|
||||
function filterError(message, statusCode = 400) {
|
||||
const err = new Error(message);
|
||||
err.statusCode = statusCode;
|
||||
return err;
|
||||
}
|
||||
|
||||
function cleanPage(page) {
|
||||
const p = String(page || '');
|
||||
if (!PAGES[p]) throw filterError('Unknown list page');
|
||||
return p;
|
||||
}
|
||||
|
||||
function cleanName(name) {
|
||||
const n = String(name || '').trim().replace(/\s+/g, ' ');
|
||||
if (!n) throw filterError('Give the filter a name');
|
||||
return n.slice(0, 60);
|
||||
}
|
||||
|
||||
/** The filter values: a plain object of strings, numbers, booleans and lists of strings. Pure. */
|
||||
function cleanParams(params) {
|
||||
if (!params || typeof params !== 'object' || Array.isArray(params)) throw filterError('Nothing to save');
|
||||
const out = {};
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (!/^[a-zA-Z][a-zA-Z0-9_]{0,39}$/.test(k)) continue;
|
||||
if (typeof v === 'string') out[k] = v.slice(0, 500);
|
||||
else if (typeof v === 'number' && Number.isFinite(v)) out[k] = v;
|
||||
else if (typeof v === 'boolean') out[k] = v;
|
||||
else if (Array.isArray(v)) out[k] = v.filter(x => typeof x === 'string').slice(0, 200).map(x => x.slice(0, 100));
|
||||
}
|
||||
if (JSON.stringify(out).length > MAX_PARAMS_BYTES) throw filterError('Too much to save in one filter');
|
||||
return out;
|
||||
}
|
||||
|
||||
const view = f => ({ id: f.id, page: f.page, name: f.name, params: f.params, isDefault: f.isDefault, updatedAt: f.updatedAt });
|
||||
|
||||
async function list(userId, page) {
|
||||
const rows = await prisma.savedFilter.findMany({ where: { userId, page: cleanPage(page) }, orderBy: [{ isDefault: 'desc' }, { name: 'asc' }] });
|
||||
return rows.map(view);
|
||||
}
|
||||
|
||||
/** Saves (or replaces, by name) a filter. Making it the default clears the old default. */
|
||||
async function save(userId, { page, name, params, isDefault = false }) {
|
||||
const p = cleanPage(page);
|
||||
const n = cleanName(name);
|
||||
const data = cleanParams(params);
|
||||
const existing = await prisma.savedFilter.findUnique({ where: { userId_page_name: { userId, page: p, name: n } } });
|
||||
if (!existing && (await prisma.savedFilter.count({ where: { userId, page: p } })) >= MAX_PER_PAGE) {
|
||||
throw filterError(`You can keep up to ${MAX_PER_PAGE} filters on a page — delete one first`);
|
||||
}
|
||||
return prisma.$transaction(async tx => {
|
||||
if (isDefault) await tx.savedFilter.updateMany({ where: { userId, page: p, isDefault: true }, data: { isDefault: false } });
|
||||
const row = existing
|
||||
? await tx.savedFilter.update({ where: { id: existing.id }, data: { params: data, ...(isDefault ? { isDefault: true } : {}) } })
|
||||
: await tx.savedFilter.create({ data: { userId, page: p, name: n, params: data, isDefault: !!isDefault } });
|
||||
return view(row);
|
||||
});
|
||||
}
|
||||
|
||||
async function own(userId, id) {
|
||||
const row = await prisma.savedFilter.findUnique({ where: { id: String(id) } });
|
||||
if (!row || row.userId !== userId) throw filterError('Saved filter not found', 404);
|
||||
return row;
|
||||
}
|
||||
|
||||
/** Rename, or make (or stop being) the default. */
|
||||
async function update(userId, id, { name, isDefault }) {
|
||||
const row = await own(userId, id);
|
||||
return prisma.$transaction(async tx => {
|
||||
if (isDefault === true) await tx.savedFilter.updateMany({ where: { userId, page: row.page, isDefault: true, NOT: { id: row.id } }, data: { isDefault: false } });
|
||||
try {
|
||||
return view(await tx.savedFilter.update({
|
||||
where: { id: row.id },
|
||||
data: { ...(name !== undefined ? { name: cleanName(name) } : {}), ...(isDefault !== undefined ? { isDefault: isDefault === true } : {}) },
|
||||
}));
|
||||
} catch (e) {
|
||||
if (e?.code === 'P2002') throw filterError('You already have a filter with that name on this page');
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function remove(userId, id) {
|
||||
const row = await own(userId, id);
|
||||
await prisma.savedFilter.delete({ where: { id: row.id } });
|
||||
}
|
||||
|
||||
module.exports = { PAGES, MAX_PER_PAGE, cleanParams, cleanName, cleanPage, list, save, update, remove };
|
||||
@@ -0,0 +1,41 @@
|
||||
const mockDb = {
|
||||
savedFilter: { findMany: jest.fn(), findUnique: jest.fn(), count: jest.fn(), create: jest.fn(), update: jest.fn(), updateMany: jest.fn(), delete: jest.fn() },
|
||||
$transaction: jest.fn(fn => fn(mockDb)),
|
||||
};
|
||||
jest.mock('../src/config/db', () => mockDb);
|
||||
const filters = require('../src/utils/savedFilters');
|
||||
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
test('only simple filter values are kept', () => {
|
||||
expect(filters.cleanParams({ q: 'Thandi', status: 'pending', past: true, events: ['a', 'b', 3], n: 2, nested: { x: 1 }, 'bad key': 'x', fn: null })).toEqual({ q: 'Thandi', status: 'pending', past: true, events: ['a', 'b'], n: 2 });
|
||||
expect(() => filters.cleanParams(null)).toThrow('Nothing to save');
|
||||
expect(() => filters.cleanParams({ q: 'x'.repeat(500), a: 'x'.repeat(500), b: 'x'.repeat(500), c: 'x'.repeat(500), d: 'x'.repeat(500), e: 'x'.repeat(500), f: 'x'.repeat(500), g: 'x'.repeat(500), h: 'x'.repeat(500) })).toThrow('Too much');
|
||||
});
|
||||
|
||||
test('pages and names are checked', async () => {
|
||||
await expect(filters.list('u1', 'secrets')).rejects.toThrow('Unknown list page');
|
||||
expect(() => filters.cleanName(' ')).toThrow('name');
|
||||
expect(filters.cleanName(' Camp — unpaid ')).toBe('Camp — unpaid');
|
||||
});
|
||||
|
||||
test('saving a default clears the old default; saving the same name replaces it', async () => {
|
||||
mockDb.savedFilter.findUnique.mockResolvedValue({ id: 'f1' });
|
||||
mockDb.savedFilter.update.mockImplementation(async ({ data }) => ({ id: 'f1', page: 'registrations', name: 'Unpaid', ...data }));
|
||||
await filters.save('u1', { page: 'registrations', name: 'Unpaid', params: { status: 'pending' }, isDefault: true });
|
||||
expect(mockDb.savedFilter.updateMany).toHaveBeenCalledWith({ where: { userId: 'u1', page: 'registrations', isDefault: true }, data: { isDefault: false } });
|
||||
expect(mockDb.savedFilter.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('at most 30 per page', async () => {
|
||||
mockDb.savedFilter.findUnique.mockResolvedValue(null);
|
||||
mockDb.savedFilter.count.mockResolvedValue(30);
|
||||
await expect(filters.save('u1', { page: 'events', name: 'One more', params: { q: 'x' } })).rejects.toThrow('up to 30');
|
||||
});
|
||||
|
||||
test('someone else\'s saved filter is "not found"', async () => {
|
||||
mockDb.savedFilter.findUnique.mockResolvedValue({ id: 'f9', userId: 'someone-else', page: 'users' });
|
||||
await expect(filters.remove('u1', 'f9')).rejects.toMatchObject({ statusCode: 404 });
|
||||
await expect(filters.update('u1', 'f9', { name: 'Mine now' })).rejects.toMatchObject({ statusCode: 404 });
|
||||
expect(mockDb.savedFilter.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
+14
-2
@@ -1,6 +1,6 @@
|
||||
# Handover — where things stand and how to carry on
|
||||
|
||||
Last updated: 2026-10-28, after **1.49.1** (and Hub 1.5.0). The plan itself is in
|
||||
Last updated: 2026-10-28, after **1.49.2** (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.2 | **Saved filters** (§9 part 3) on registrations, users, events and reports (`/api/saved-filters`, `components/shared/SavedFilters.tsx`). Migration `20261028180000_saved_filters` |
|
||||
| 1.49.1 | **Bulk actions** on Manage registrations (§9 part 2): message, mark paid, move ticket, allocate, export, cancel (`POST /api/registrations/bulk`, `utils/bulkRegistrations.js`). Migration `20261028170000_registrations_bulk` |
|
||||
| 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` |
|
||||
@@ -57,7 +58,9 @@ 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.2 — saved filters** per user on list pages (§9), then 1.50.0 Afrikaans and 1.51.0 accessibility.
|
||||
1. **1.50.0 — Accessibility pass (WCAG 2.2 AA)** (§11), then **1.51.0 — TypeScript backend, OpenAPI
|
||||
and `/api/v2`** (§14), then **2.0.0 — AI helpers** (§15). Languages (Afrikaans, §10) are removed
|
||||
from scope (2026-10-28).
|
||||
|
||||
## Working on this repo
|
||||
|
||||
@@ -405,6 +408,15 @@ a test server (both done 2026-10-28).
|
||||
goes through `/api/reports/excel` (access-logged). Tests: `tests/bulkRegistrations.test.js`, e2e
|
||||
`bulk.spec.ts`.
|
||||
|
||||
## Saved filters (1.49.2)
|
||||
|
||||
- `backend/src/utils/savedFilters.js` (`PAGES` is the list of pages with saved filters; values are
|
||||
cleaned to simple types) + `routes/savedFilterRoutes.js`. Removed in `anonymiseUser`.
|
||||
- `components/shared/SavedFilters.tsx` takes the page's current filter values and an `onApply`;
|
||||
`skipDefault` when the URL already says what to show. **Adding it to another list page:** add the
|
||||
page to `PAGES` (and the component's `page` type), then pass the values in and apply them out.
|
||||
- e2e `saved-filters.spec.ts` (deletes what it saves).
|
||||
|
||||
## Known gaps / follow-ups
|
||||
|
||||
- WhatsApp STOP only works once someone pastes the webhook URL from Site settings → WhatsApp
|
||||
|
||||
+11
-7
@@ -36,7 +36,9 @@ ratios, offline desk). 1.46.0 **volunteer rosters** shipped (roles per event or
|
||||
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. 1.49.0 **dashboard (Today, Waiting for you) and Ctrl+K palette**
|
||||
shipped; 1.49.1 **bulk actions** shipped. **Next: 1.49.2 saved filters.**
|
||||
shipped; 1.49.1 **bulk actions** and 1.49.2 **saved filters** shipped — §9 is done. Languages
|
||||
(Afrikaans) are removed from scope (2026-10-28), so everything after moves up by one. **Next: 1.50.0
|
||||
Accessibility pass (WCAG 2.2 AA).**
|
||||
|
||||
**Repositories:**
|
||||
|
||||
@@ -58,7 +60,7 @@ shipped; 1.49.1 **bulk actions** shipped. **Next: 1.49.2 saved filters.**
|
||||
7. ~~[Apple and Google Wallet tickets](#7-apple-and-google-wallet-tickets)~~ — removed from scope
|
||||
8. [Outbound webhooks](#8-outbound-webhooks)
|
||||
9. [Admin dashboard, command palette, bulk actions](#9-admin-dashboard-command-palette-bulk-actions)
|
||||
10. [Languages (Afrikaans first)](#10-languages-afrikaans-first)
|
||||
10. ~~[Languages (Afrikaans first)](#10-languages-afrikaans-first)~~ — removed from scope
|
||||
11. [Accessibility pass (WCAG 2.2 AA)](#11-accessibility-pass-wcag-22-aa)
|
||||
12. [Multi-campus (one install per church)](#12-multi-campus-one-install-per-church)
|
||||
13. [Central management platform (Cross Events Hub)](#13-central-management-platform-cross-events-hub)
|
||||
@@ -518,7 +520,7 @@ 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.
|
||||
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
|
||||
@@ -533,6 +535,8 @@ palette ✅; **1.49.1** bulk actions ✅; **1.49.2** saved filters.
|
||||
|
||||
## 10. Languages (Afrikaans first)
|
||||
|
||||
> **Removed from scope (2026-10-28).** Kept here for reference only; not planned for 2.0.
|
||||
|
||||
- i18n framework on the frontend (e.g. `next-intl`); English + Afrikaans.
|
||||
- User language preference; emails, WhatsApp messages and PDFs sent in the
|
||||
user's language, with per-language templates.
|
||||
@@ -939,8 +943,7 @@ each confirmed action in the audit log.
|
||||
### More helpers
|
||||
|
||||
Smaller one-button additions as they come up: suggest an event's price tiers from past
|
||||
events, write a post-event thank-you, draft certificate wording, translate a message into
|
||||
Afrikaans (ties in with §10), flag unusual payments for the cashup.
|
||||
events, write a post-event thank-you, draft certificate wording, flag unusual payments for the cashup.
|
||||
|
||||
### Self-hosted AI (long term)
|
||||
|
||||
@@ -971,8 +974,8 @@ 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.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 |
|
||||
| 10 | 1.49.0 ✅ – 1.49.2 ✅, 1.50.0 | Admin dashboard/Ctrl+K/bulk actions/saved filters, Accessibility pass (Afrikaans removed from scope) | Polish before the big release |
|
||||
| 11 | 1.51.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 |
|
||||
|
||||
Each phase should add matching help content (`frontend/src/content/help/`),
|
||||
@@ -1023,6 +1026,7 @@ feature.
|
||||
| — | Multi-campus split | Two releases: **1.43.0 campuses**, **1.44.0 campus-scoped staff**; everything after moves up by one. Scoped staff can view and scan "All campuses" events, only admins and unscoped supervisors edit them. "Everyone at campus X" = home campus X, plus (tick box) registrants of campus X's events in the last 12 months. Campus landing pages are in 1.43.0 (2026-10-27) |
|
||||
| — | Updates | Sites show **Request update**, never the deploy command: the request goes to the Hub, which tells Joshua; he updates the server. 1.42.0 / Hub 1.1.0; Multi-campus and everything after moves up by one (2026-10-27) |
|
||||
| — | Wallet passes | **Removed from scope** (2026-10-28); everything after volunteer rosters moves up by one |
|
||||
| — | Languages (Afrikaans) | **Removed from scope** (2026-10-28); the accessibility pass becomes 1.50.0 and the TypeScript backend / `/api/v2` 1.51.0 |
|
||||
| — | Kids check-in (1.45.0) | One release. **Fully digital** — no label printers: pickup code on the parent's phone, a "write on the sticker" panel for blank stickers. Staff desk only (no kiosk). Anyone with the pickup code can collect; families list authorised adults without accounts. Child profiles deleted after 6 months without a check-in (2026-10-28) |
|
||||
| — | Volunteer rosters (1.46.0) | Anyone with an account can volunteer. Roles are per event (or per session) — rosters cover one event/session and never repeat. A swap needs no approval: it happens once someone accepts, and the event's notify recipients are emailed. Volunteers check in by scanning a QR code or are checked in by staff. The free volunteer ticket is off by default and chosen per event. A role linked to a kids room counts its checked-in volunteers as leaders on duty for the room's ratio (2026-10-28) |
|
||||
| — | Event series and cloning (1.47.x) | One release per part: 1.47.0 clone, 1.47.1 series, 1.47.2 year-on-year reports, 1.47.3 season pass. A clone opens the New event popup filled in from the source, so everything is set there before saving. Of the newer features a clone copies only the campus (not kids check-in or volunteer roles); coupons are never copied. A series can span several campuses. Following a series is **News & announcements**; copies keep the source's certificate and reminder settings. Season pass: one checkout registers for every event on it, the supervisor picks the events, the price is split in proportion to normal prices (2026-10-28) |
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { test, expect, loginAs } from "./fixtures";
|
||||
|
||||
// Saved filters on list pages (1.49.2): save the current filters, make them the default (applied
|
||||
// when the page opens), and delete them again.
|
||||
test("an admin saves a registrations filter as the default, and deletes it", async ({ page }) => {
|
||||
await loginAs(page, "admin");
|
||||
await page.goto("/dashboard/admin/registrations");
|
||||
const status = page.locator("select").filter({ has: page.locator("option", { hasText: "All statuses" }) });
|
||||
await status.selectOption("cancelled");
|
||||
|
||||
const saved = page.getByTestId("saved-filters");
|
||||
await saved.getByRole("button", { name: /Saved filters/ }).click();
|
||||
await saved.getByLabel("Save the current filters as").fill("Cancelled ones");
|
||||
await saved.getByLabel("Use when this page opens").check();
|
||||
await saved.getByRole("button", { name: "Save", exact: true }).click();
|
||||
await expect(saved.getByRole("button", { name: "Cancelled ones" }).first()).toBeVisible();
|
||||
|
||||
// The default is applied when the page opens…
|
||||
await page.reload();
|
||||
await expect(status).toHaveValue("cancelled");
|
||||
await expect(saved.getByRole("button", { name: /Cancelled ones/ }).first()).toBeVisible();
|
||||
|
||||
// …but not over a link that says what to show.
|
||||
await page.goto("/dashboard/admin/registrations?status=paid");
|
||||
await expect(status).toHaveValue("paid");
|
||||
|
||||
await saved.getByRole("button", { name: /Saved filters/ }).click();
|
||||
page.once("dialog", d => d.accept());
|
||||
await saved.getByRole("button", { name: "Delete Cancelled ones" }).click();
|
||||
await expect(saved.getByText("No saved filters yet")).toBeVisible();
|
||||
await page.goto("/dashboard/admin/registrations");
|
||||
await expect(status).toHaveValue("");
|
||||
});
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.49.1",
|
||||
"version": "1.49.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.49.1",
|
||||
"version": "1.49.2",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.49.1",
|
||||
"version": "1.49.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
|
||||
@@ -10,6 +10,7 @@ import { errorMessage } from "@/lib/utils";
|
||||
import type { Event, FormResponse, Registration } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { RegistrationBulkBar } from "@/components/admin/RegistrationBulkBar";
|
||||
import { SavedFilters } from "@/components/shared/SavedFilters";
|
||||
|
||||
const STATUS_OPTIONS = ["pending", "confirmed", "partial_paid", "paid", "cancelled"] as const;
|
||||
|
||||
@@ -292,6 +293,15 @@ export default function AdminRegistrationsPage() {
|
||||
<button className="text-sm px-2 py-1.5 rounded bg-gray-100 hover:bg-gray-200" onClick={loadRegistrations} disabled={loadingRegs}>
|
||||
{loadingRegs ? "Loading…" : "Refresh"}
|
||||
</button>
|
||||
<SavedFilters
|
||||
page="registrations"
|
||||
value={{ q: query, event: eventFilter, status: statusFilter, past: includePastEvents, inactive: includeInactiveEvents }}
|
||||
onApply={p => {
|
||||
setQuery(String(p.q || "")); setEventFilter(String(p.event || "")); setStatusFilter(String(p.status || ""));
|
||||
setIncludePastEvents(p.past === true); setIncludeInactiveEvents(p.inactive === true);
|
||||
}}
|
||||
skipDefault={typeof window !== "undefined" && /[?&](q|status|event)=/.test(window.location.search)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-gray-500">{filtered.length} of {registrations.length} registrations</div>
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { errorMessage } from "@/lib/utils";
|
||||
import type { Paginated } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { useCampuses } from "@/hooks/useCampuses";
|
||||
import { SavedFilters } from "@/components/shared/SavedFilters";
|
||||
|
||||
interface UserItem {
|
||||
id: string;
|
||||
@@ -359,6 +360,17 @@ export default function AdminUsersPage() {
|
||||
<option value="false">Inactive only</option>
|
||||
</select>
|
||||
</div>
|
||||
<SavedFilters
|
||||
page="users" className="self-end"
|
||||
value={{ q: query, role: roleFilter, active: activeFilter }}
|
||||
onApply={p => {
|
||||
const q = String(p.q || "");
|
||||
setQuery(q); setDebouncedQuery(q);
|
||||
setRoleFilter(String(p.role || "") as Role | "");
|
||||
setActiveFilter(String(p.active || "") as "" | "true" | "false");
|
||||
}}
|
||||
skipDefault={typeof window !== "undefined" && /[?&]q=/.test(window.location.search)}
|
||||
/>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Per page</label>
|
||||
<select className="border rounded px-2 py-1.5 text-sm" value={pageSize} onChange={e => handlePageSizeChange(Number(e.target.value))}>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
|
||||
import { useMyCampuses } from "@/hooks/useCampuses";
|
||||
import { CampusBadge, CampusFilter, matchesCampus } from "@/components/shared/CampusFilter";
|
||||
import { SavedFilters } from "@/components/shared/SavedFilters";
|
||||
import { Calendar, Copy } from "lucide-react";
|
||||
import { ChoicesEditor } from "@/components/shared/FormFieldExtras";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
@@ -2159,6 +2160,14 @@ export default function ManageEventsPage() {
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" checked={showClosed} onChange={e => setShowClosed(e.target.checked)} /> Show closed
|
||||
</label>
|
||||
<SavedFilters
|
||||
page="events"
|
||||
value={{ q: search, campus, past: includePast, inactive: showInactive, closed: showClosed }}
|
||||
onApply={p => {
|
||||
setSearch(String(p.q || "")); setCampus(String(p.campus || ""));
|
||||
setIncludePast(p.past === true); setShowInactive(p.inactive === true); setShowClosed(p.closed === true);
|
||||
}}
|
||||
/>
|
||||
{loadingEvents && <span className="text-xs text-gray-500">Loading…</span>}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import { AreaTrendChart } from "@/components/charts/AreaTrendChart";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { registrationOptionUnitPrice } from "@/lib/pricing";
|
||||
import { CampusFilter, matchesCampus } from "@/components/shared/CampusFilter";
|
||||
import { SavedFilters } from "@/components/shared/SavedFilters";
|
||||
import type { ConsentRow, Event, EventCashup, EventFinancials, EventSeries, MedicalRow, Payment, Registration, SeriesComparison, Ticket, UserSummary } from "@/types";
|
||||
|
||||
const METHOD_KEYS = ["cash", "card", "eft", "other"] as const;
|
||||
@@ -1512,15 +1513,29 @@ export default function ReportsV2({ backHref }: { backHref?: string } = {}) {
|
||||
)}
|
||||
{error && <div className="p-3 mb-3 border rounded bg-red-50 text-red-700 text-sm">{error}</div>}
|
||||
|
||||
<CampusFilter
|
||||
value={campus}
|
||||
onChange={v => { setCampus(v); setSelectedEventIds([]); }}
|
||||
withChurchWide
|
||||
everyLabel="All campuses (church-wide total)"
|
||||
className="mb-3"
|
||||
id="reports-campus"
|
||||
mine
|
||||
/>
|
||||
<div className="flex flex-wrap items-start gap-3 mb-3">
|
||||
<CampusFilter
|
||||
value={campus}
|
||||
onChange={v => { setCampus(v); setSelectedEventIds([]); }}
|
||||
withChurchWide
|
||||
everyLabel="All campuses (church-wide total)"
|
||||
id="reports-campus"
|
||||
mine
|
||||
/>
|
||||
<SavedFilters
|
||||
page="reports"
|
||||
value={{ report, events: selectedEventIds, from: dateFrom, to: dateTo, campus, past: showPastEvents, inactive: showInactiveEvents, closed: showClosedEvents }}
|
||||
onApply={p => {
|
||||
if (typeof p.report === "string" && REPORTS.some(r => r.key === p.report)) setReport(p.report as ReportKey);
|
||||
setCampus(String(p.campus || ""));
|
||||
setShowPastEvents(p.past === true); setShowInactiveEvents(p.inactive === true); setShowClosedEvents(p.closed !== false);
|
||||
setSelectedEventIds(Array.isArray(p.events) ? p.events : []);
|
||||
setDateFrom(String(p.from || "")); setDateTo(String(p.to || ""));
|
||||
setReady(false);
|
||||
}}
|
||||
skipDefault={!!searchParams.get("report")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ReportsShell
|
||||
events={filteredEvents}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Bookmark, Star, Trash2, Check } from "lucide-react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
|
||||
export type FilterValues = Record<string, string | number | boolean | string[]>;
|
||||
type Saved = { id: string; name: string; params: FilterValues; isDefault: boolean };
|
||||
|
||||
/**
|
||||
* Saved filters on a list page (1.49.2): apply one with a click, save the current filters under a
|
||||
* name, star one as the default (applied when the page opens, unless the link already says what
|
||||
* to show — `skipDefault`). Stored per person on the server.
|
||||
*/
|
||||
export function SavedFilters({ page, value, onApply, skipDefault = false, className = "" }: {
|
||||
page: "registrations" | "users" | "events" | "reports";
|
||||
value: FilterValues;
|
||||
onApply: (params: FilterValues) => void;
|
||||
skipDefault?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const { token } = useAuth();
|
||||
const [list, setList] = useState<Saved[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [asDefault, setAsDefault] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [applied, setApplied] = useState<string | null>(null);
|
||||
const defaultDone = useRef(false);
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
const rows = await apiFetch<Saved[]>(`/api/saved-filters?page=${page}`, { authToken: token });
|
||||
setList(rows);
|
||||
// The default, once, when the page opens.
|
||||
if (!defaultDone.current) {
|
||||
defaultDone.current = true;
|
||||
const def = rows.find(r => r.isDefault);
|
||||
if (def && !skipDefault) { onApply(def.params); setApplied(def.id); }
|
||||
}
|
||||
} catch { /* the page works without them */ }
|
||||
// onApply/skipDefault are read once, for the default.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [token, page]);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
// Close when clicking elsewhere.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDown = (e: MouseEvent) => { if (boxRef.current && !boxRef.current.contains(e.target as Node)) setOpen(false); };
|
||||
document.addEventListener("mousedown", onDown);
|
||||
return () => document.removeEventListener("mousedown", onDown);
|
||||
}, [open]);
|
||||
|
||||
const act = async (fn: () => Promise<unknown>) => {
|
||||
setError(null);
|
||||
try { await fn(); await load(); } catch (e) { setError(errorMessage(e) || "Couldn't save that"); }
|
||||
};
|
||||
const save = () => act(async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const row = await apiFetch<Saved>("/api/saved-filters", { method: "POST", authToken: token || undefined, body: { page, name, params: value, isDefault: asDefault } });
|
||||
setApplied(row.id); setName(""); setAsDefault(false);
|
||||
} finally { setSaving(false); }
|
||||
});
|
||||
const toggleDefault = (f: Saved) => act(() => apiFetch(`/api/saved-filters/${f.id}`, { method: "PUT", authToken: token || undefined, body: { isDefault: !f.isDefault } }));
|
||||
const remove = (f: Saved) => act(async () => {
|
||||
if (!confirm(`Delete the saved filter "${f.name}"?`)) return;
|
||||
await apiFetch(`/api/saved-filters/${f.id}`, { method: "DELETE", authToken: token || undefined });
|
||||
});
|
||||
|
||||
const current = list.find(f => f.id === applied);
|
||||
return (
|
||||
<div className={`relative inline-block ${className}`} ref={boxRef} data-testid="saved-filters">
|
||||
<button type="button" onClick={() => setOpen(o => !o)} aria-expanded={open}
|
||||
className="inline-flex items-center gap-1.5 text-sm px-2.5 py-1.5 rounded border bg-white hover:bg-gray-50">
|
||||
<Bookmark className="w-4 h-4" /> {current ? current.name : "Saved filters"}{list.length > 0 && !current ? ` (${list.length})` : ""}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute z-30 mt-1 w-72 right-0 sm:left-0 sm:right-auto rounded-xl border bg-white shadow-lg p-2 space-y-2" role="dialog" aria-label="Saved filters">
|
||||
{list.length === 0 ? (
|
||||
<p className="text-xs text-gray-500 px-1">No saved filters yet. Set the filters the way you like them and save them here.</p>
|
||||
) : (
|
||||
<ul className="max-h-60 overflow-y-auto">
|
||||
{list.map(f => (
|
||||
<li key={f.id} className="flex items-center gap-1 rounded hover:bg-gray-50">
|
||||
<button type="button" className="flex-1 text-left text-sm px-2 py-1.5 truncate" onClick={() => { onApply(f.params); setApplied(f.id); setOpen(false); }}>
|
||||
{applied === f.id && <Check className="inline w-3.5 h-3.5 mr-1 text-brand-600" />}{f.name}
|
||||
</button>
|
||||
<button type="button" onClick={() => toggleDefault(f)} title={f.isDefault ? "Default — click to stop" : "Make this the default when the page opens"} aria-label={f.isDefault ? `${f.name} is the default` : `Make ${f.name} the default`} className="p-1">
|
||||
<Star className={`w-4 h-4 ${f.isDefault ? "fill-amber-400 text-amber-500" : "text-gray-300 hover:text-gray-500"}`} />
|
||||
</button>
|
||||
<button type="button" onClick={() => remove(f)} aria-label={`Delete ${f.name}`} className="p-1 text-gray-300 hover:text-red-600"><Trash2 className="w-4 h-4" /></button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<div className="border-t pt-2 space-y-1.5">
|
||||
<label className="block text-xs text-gray-600" htmlFor={`save-filter-${page}`}>Save the current filters as</label>
|
||||
<div className="flex gap-1.5">
|
||||
<input id={`save-filter-${page}`} className="flex-1 min-w-0 border rounded px-2 py-1 text-sm" placeholder="e.g. Camp — unpaid" value={name} onChange={e => setName(e.target.value)} onKeyDown={e => { if (e.key === "Enter" && name.trim()) save(); }} />
|
||||
<button type="button" disabled={saving || !name.trim()} onClick={save} className="px-2.5 py-1 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50">Save</button>
|
||||
</div>
|
||||
<label className="flex items-center gap-1.5 text-xs text-gray-600"><input type="checkbox" checked={asDefault} onChange={e => setAsDefault(e.target.checked)} /> Use when this page opens</label>
|
||||
{error && <p className="text-xs text-red-600">{error}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { ClipboardList, Filter, Ban, CheckSquare } from "lucide-react";
|
||||
import { ClipboardList, Filter, Ban, CheckSquare, Bookmark } from "lucide-react";
|
||||
import { GuideItem } from "@/components/shared/GuideItem";
|
||||
import type { HelpContent } from "./types";
|
||||
|
||||
@@ -26,6 +26,9 @@ export const dashboardAdminRegistrationsHelpContent: HelpContent = {
|
||||
<div className="space-y-4">
|
||||
<p>Click a row to expand it and see ticket options (with early-bird flags where applied), the payment history, and any submitted form responses.</p>
|
||||
<div className="space-y-4">
|
||||
<GuideItem icon={Bookmark} title="Saved filters" tone="amber">
|
||||
Set the filters the way you use them, then Saved filters → name them and Save. Click one to apply it; the star makes it the one used when the page opens. They're yours (other people don't see them) and follow you to any device. The Users, Events and Reports pages have them too.
|
||||
</GuideItem>
|
||||
<GuideItem icon={CheckSquare} title="Doing many at once" tone="violet">
|
||||
Tick registrations (or "Select all shown" after filtering) and choose an action: send a message (email or WhatsApp, as an event update), mark paid (records what each still owes, in cash, EFT, card or other — tickets go out by email), move to another ticket (their tickets and QR codes stay the same; the price follows the new ticket), put their named people in a room or group, export to Excel, or cancel. Each one follows the same rules as doing it one at a time, and you see what happened to each. Moving tickets and groups need registrations from one event.
|
||||
</GuideItem>
|
||||
|
||||
@@ -29,7 +29,7 @@ export const reportsHelpContent: HelpContent = {
|
||||
Preview your report, adjust filters, and choose how you want the data to appear.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Download} title="Export or email" tone="amber">
|
||||
Export your report to Excel, PDF, or send it by email or WhatsApp. Tip: select several events in the Feedback summary to compare their ratings (e.g. Camp 2025 vs Camp 2026), or use Series: year on year to compare a whole series' registrations, income and attendance. The Registration funnel shows where people drop off between viewing an event and paying, by source and device. The source comes from short links and utm_source links, else the site the visitor came from (Facebook, Instagram, Google…). The event page's Share and Save QR buttons tag their links as "share" and "qr". WhatsApp doesn't say where a click came from, so add ?utm_source=whatsapp to links you post there, or they show as "direct". It counts only visitors who allowed cookies.
|
||||
Export your report to Excel, PDF, or send it by email or WhatsApp. Saved filters (next to the campus filter) keep a report with its events, dates and options under a name — star one to have it ready when the page opens. Tip: select several events in the Feedback summary to compare their ratings (e.g. Camp 2025 vs Camp 2026), or use Series: year on year to compare a whole series' registrations, income and attendance. The Registration funnel shows where people drop off between viewing an event and paying, by source and device. The source comes from short links and utm_source links, else the site the visitor came from (Facebook, Instagram, Google…). The event page's Share and Save QR buttons tag their links as "share" and "qr". WhatsApp doesn't say where a click came from, so add ?utm_source=whatsapp to links you post there, or they show as "direct". It counts only visitors who allowed cookies.
|
||||
</GuideItem>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.49.1",
|
||||
"version": "1.49.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events",
|
||||
"version": "1.49.1",
|
||||
"version": "1.49.2",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.64.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.49.1",
|
||||
"version": "1.49.2",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev:backend": "cd backend && npm run dev",
|
||||
|
||||
Reference in New Issue
Block a user