Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4bcb07f9d1 | ||
|
|
a0a3dc2416 | ||
|
|
51639322e2 | ||
|
|
1f186d6d2a | ||
|
|
55e7ca9546 | ||
|
|
8f69e58aa2 | ||
|
|
1b2f2a7c79 | ||
|
|
7906248c67 | ||
|
|
22ffbf201e | ||
|
|
be499f0d66 | ||
|
|
75b2f8ccc5 | ||
|
|
4f003f1628 | ||
|
|
b3ff2b9c5e |
+5
-1
@@ -33,4 +33,8 @@ Thumbs.db
|
|||||||
|
|
||||||
# misc scratch / generated files
|
# misc scratch / generated files
|
||||||
temp/
|
temp/
|
||||||
backend/public/uploads/
|
backend/public/uploads/
|
||||||
|
|
||||||
|
# runtime data stores (mutated by the running app, not source)
|
||||||
|
backend/data/scheduled-emails.json
|
||||||
|
backend/data/banner.json
|
||||||
@@ -7,6 +7,36 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [1.5.3] - 2026-08-07
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Help guide popups could render taller than the screen on mobile, with no way to reach the close button or "Got it" button: only the tab content area had a height cap, so the header, quick links, and footer weren't accounted for. The whole popup is now capped to the screen height, with just the tab content scrolling internally.
|
||||||
|
|
||||||
|
## [1.5.2] - 2026-08-07
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Stat card labels on the Admin and Supervisor dashboards (e.g. "Registrations (past month)") were getting cut off mid-word on mobile, where a cramped 2-column grid left too little room for the text. The mobile layout now shows one card per row, and labels wrap onto two lines instead of truncating.
|
||||||
|
- The "Revenue trend" and "Top performing events" cards on the Admin and Supervisor dashboards could render outside the viewport on mobile: a wide events table (long titles, four columns) forced the containing grid column past the screen width instead of scrolling internally. Long event titles are now truncated in the table, and the page properly contains horizontal overflow.
|
||||||
|
- The navbar Logout button sat about 2px lower than the other nav links (Home/Events/Contact/Dashboard) because it was missing the same underline-spacing classes those links use.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Tab/mode switcher buttons on the Payments, At the door, Manual registration, Email attendees, WhatsApp attendees, and Cashup detail pages now show icons, matching the icons already used for the same tabs in each page's help guide.
|
||||||
|
|
||||||
|
## [1.5.1] - 2026-08-06
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Scheduling a WhatsApp message was silently sent as an email instead: the scheduled-job store never persisted the `channel` field, so the send worker always fell through to its email branch regardless of what was requested. Scheduled WhatsApp jobs now correctly send via WhatsApp.
|
||||||
|
- Scheduled emails/WhatsApp messages showed no content when viewing or editing them in "Manage scheduled", even though the message existed in storage: the list endpoint never returned the message body (`html`/`text` for email, `message` for WhatsApp), and editing a scheduled WhatsApp message saved to the wrong payload field (`text` instead of `message`), so edits were silently lost. Both are now fixed, and the email list also shows a body preview like the WhatsApp one already did.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Scheduled emails/WhatsApp messages are now automatically purged from storage 24 hours after they're sent, instead of accumulating indefinitely.
|
||||||
|
- The "Manage scheduled" lists on the Email Attendees and WhatsApp Attendees pages now show who each scheduled job will be sent to, and each tab only shows jobs for its own channel (previously both tabs showed the same unfiltered list).
|
||||||
|
|
||||||
## [1.5.0] - 2026-08-06
|
## [1.5.0] - 2026-08-06
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"message": "",
|
|
||||||
"type": "info",
|
|
||||||
"liveFrom": null,
|
|
||||||
"liveTill": null
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"jobs": []
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "event-management-backend",
|
"name": "event-management-backend",
|
||||||
"version": "1.5.0",
|
"version": "1.5.3",
|
||||||
"description": "Event Management System Backend",
|
"description": "Event Management System Backend",
|
||||||
"main": "src/index.js",
|
"main": "src/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -57,6 +57,23 @@ function parseFreeformEmails(lines) {
|
|||||||
return recipients;
|
return recipients;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Human-readable summary of who a broadcast will go to, for the scheduled-jobs admin UI
|
||||||
|
async function describeBroadcastRecipients({ userIds, emails }) {
|
||||||
|
const parts = [];
|
||||||
|
try {
|
||||||
|
const ids = Array.isArray(userIds) ? userIds.filter(x => typeof x === 'string' && x) : [];
|
||||||
|
if (ids.length) {
|
||||||
|
const users = await prisma.user.findMany({ where: { id: { in: ids } }, select: { name: true } });
|
||||||
|
const names = users.map(u => u.name).filter(Boolean);
|
||||||
|
parts.push(names.slice(0, 3).join(', ') + (names.length > 3 ? ` +${names.length - 3} more` : ''));
|
||||||
|
}
|
||||||
|
if (Array.isArray(emails) && emails.length) {
|
||||||
|
parts.push(`${emails.length} email address${emails.length === 1 ? '' : 'es'}`);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return parts.length ? parts.join('; ') : 'No recipients';
|
||||||
|
}
|
||||||
|
|
||||||
// @desc Preview broadcast recipients and sample
|
// @desc Preview broadcast recipients and sample
|
||||||
// @route POST /api/broadcasts/preview
|
// @route POST /api/broadcasts/preview
|
||||||
// @access Private/Supervisor or Admin
|
// @access Private/Supervisor or Admin
|
||||||
@@ -191,11 +208,14 @@ const scheduleBroadcast = async (req, res) => {
|
|||||||
|
|
||||||
const payload = { subject, html, text, userIds, emails, eventId };
|
const payload = { subject, html, text, userIds, emails, eventId };
|
||||||
|
|
||||||
|
const recipientSummary = await describeBroadcastRecipients({ userIds, emails: parseFreeformEmails(emails) });
|
||||||
|
|
||||||
const { addJob } = require('../utils/scheduledEmails');
|
const { addJob } = require('../utils/scheduledEmails');
|
||||||
const created = addJob({
|
const created = addJob({
|
||||||
broadcast: true,
|
broadcast: true,
|
||||||
scheduledAt: when.toISOString(),
|
scheduledAt: when.toISOString(),
|
||||||
createdById: req.user?.id || null,
|
createdById: req.user?.id || null,
|
||||||
|
recipientSummary,
|
||||||
payload,
|
payload,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1569,6 +1569,15 @@ const whatsappEventAttendees = async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Human-readable summary of who an attendees-scoped send will go to, for the scheduled-jobs admin UI
|
||||||
|
function describeAttendeeFilter(filter) {
|
||||||
|
if (Array.isArray(filter?.attendeeIds) && filter.attendeeIds.length > 0) {
|
||||||
|
return `${filter.attendeeIds.length} selected attendee${filter.attendeeIds.length === 1 ? '' : 's'}`;
|
||||||
|
}
|
||||||
|
const labels = { paid: 'Paid attendees', unpaid: 'Unpaid attendees', partial_paid: 'Partially paid attendees', cancelled: 'Cancelled registrations' };
|
||||||
|
return labels[filter?.status] || 'All attendees';
|
||||||
|
}
|
||||||
|
|
||||||
// @desc Schedule email to attendees at a specific date/time
|
// @desc Schedule email to attendees at a specific date/time
|
||||||
// @route POST /api/events/:id/email-attendees/schedule
|
// @route POST /api/events/:id/email-attendees/schedule
|
||||||
// @access Private/Supervisor or Admin
|
// @access Private/Supervisor or Admin
|
||||||
@@ -1600,6 +1609,7 @@ const scheduleEmailEventAttendees = async (req, res) => {
|
|||||||
eventId,
|
eventId,
|
||||||
createdById: req.user?.id || null,
|
createdById: req.user?.id || null,
|
||||||
scheduledAt: when.toISOString(),
|
scheduledAt: when.toISOString(),
|
||||||
|
recipientSummary: `${event.title} — ${describeAttendeeFilter(filter)}`,
|
||||||
payload,
|
payload,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1637,6 +1647,7 @@ const scheduleWhatsappEventAttendees = async (req, res) => {
|
|||||||
channel: 'whatsapp',
|
channel: 'whatsapp',
|
||||||
createdById: req.user?.id || null,
|
createdById: req.user?.id || null,
|
||||||
scheduledAt: when.toISOString(),
|
scheduledAt: when.toISOString(),
|
||||||
|
recipientSummary: `${event.title} — ${describeAttendeeFilter(filter)}`,
|
||||||
payload,
|
payload,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,11 +6,14 @@ function toClient(job) {
|
|||||||
const subject = job?.payload?.subject || '';
|
const subject = job?.payload?.subject || '';
|
||||||
const html = job?.payload?.html || '';
|
const html = job?.payload?.html || '';
|
||||||
const text = job?.payload?.text || '';
|
const text = job?.payload?.text || '';
|
||||||
|
const message = job?.payload?.message || '';
|
||||||
return {
|
return {
|
||||||
id: job.id,
|
id: job.id,
|
||||||
kind,
|
kind,
|
||||||
eventId: job.eventId || null,
|
eventId: job.eventId || null,
|
||||||
broadcast: !!job.broadcast,
|
broadcast: !!job.broadcast,
|
||||||
|
channel: job.channel || 'email',
|
||||||
|
recipient: job.recipientSummary || null,
|
||||||
scheduledAt: job.scheduledAt,
|
scheduledAt: job.scheduledAt,
|
||||||
createdAt: job.createdAt,
|
createdAt: job.createdAt,
|
||||||
status: job.status,
|
status: job.status,
|
||||||
@@ -18,26 +21,21 @@ function toClient(job) {
|
|||||||
sentAt: job.sentAt || null,
|
sentAt: job.sentAt || null,
|
||||||
lastError: job.lastError || null,
|
lastError: job.lastError || null,
|
||||||
subject,
|
subject,
|
||||||
|
html,
|
||||||
|
text,
|
||||||
|
message,
|
||||||
hasHtml: !!html,
|
hasHtml: !!html,
|
||||||
hasText: !!text,
|
hasText: !!text,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /api/scheduled-emails
|
// GET /api/scheduled-emails?channel=email|whatsapp
|
||||||
// Returns jobs excluding emails sent more than a week ago
|
// Sent jobs are purged from storage 24h after sending, so nothing older than that is ever returned here
|
||||||
const listScheduledEmails = async (req, res) => {
|
const listScheduledEmails = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const raw = listJobs();
|
const { channel } = req.query || {};
|
||||||
const now = new Date();
|
const raw = listJobs(channel ? { channel: String(channel) } : {});
|
||||||
const weekMs = 7 * 24 * 60 * 60 * 1000;
|
const filtered = raw
|
||||||
const filtered = raw.filter(j => {
|
|
||||||
if (j.status === 'sent' && j.sentAt) {
|
|
||||||
const sentAt = new Date(j.sentAt).getTime();
|
|
||||||
return (now.getTime() - sentAt) <= weekMs;
|
|
||||||
}
|
|
||||||
// Include queued, sending, error by default
|
|
||||||
return true;
|
|
||||||
})
|
|
||||||
// Provide most-relevant first: queued -> sending -> error -> recent sent
|
// Provide most-relevant first: queued -> sending -> error -> recent sent
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
const order = { queued: 0, sending: 1, error: 2, sent: 3 };
|
const order = { queued: 0, sending: 1, error: 2, sent: 3 };
|
||||||
@@ -56,7 +54,7 @@ const listScheduledEmails = async (req, res) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// PATCH /api/scheduled-emails/:id
|
// PATCH /api/scheduled-emails/:id
|
||||||
// Allows editing scheduledAt, subject, html/text on queued jobs only
|
// Allows editing scheduledAt and message content (subject/html/text for email jobs, message for WhatsApp jobs) on queued jobs only
|
||||||
const updateScheduledEmail = async (req, res) => {
|
const updateScheduledEmail = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
@@ -64,7 +62,7 @@ const updateScheduledEmail = async (req, res) => {
|
|||||||
if (!job) return res.status(404).json({ message: 'Job not found' });
|
if (!job) return res.status(404).json({ message: 'Job not found' });
|
||||||
if (job.status !== 'queued') return res.status(400).json({ message: 'Only queued jobs can be edited' });
|
if (job.status !== 'queued') return res.status(400).json({ message: 'Only queued jobs can be edited' });
|
||||||
|
|
||||||
const { scheduledAt, subject, html, text } = req.body || {};
|
const { scheduledAt, subject, html, text, message } = req.body || {};
|
||||||
|
|
||||||
const patch = {};
|
const patch = {};
|
||||||
if (scheduledAt) {
|
if (scheduledAt) {
|
||||||
@@ -72,14 +70,12 @@ const updateScheduledEmail = async (req, res) => {
|
|||||||
if (isNaN(when.getTime())) return res.status(400).json({ message: 'scheduledAt must be a valid ISO date-time' });
|
if (isNaN(when.getTime())) return res.status(400).json({ message: 'scheduledAt must be a valid ISO date-time' });
|
||||||
patch.scheduledAt = when.toISOString();
|
patch.scheduledAt = when.toISOString();
|
||||||
}
|
}
|
||||||
if (subject != null || html != null || text != null) {
|
if (subject != null || html != null || text != null || message != null) {
|
||||||
const payload = { ...(job.payload || {}) };
|
const payload = { ...(job.payload || {}) };
|
||||||
if (subject != null) payload.subject = subject;
|
if (subject != null) payload.subject = subject;
|
||||||
if (html != null || text != null) {
|
if (html != null) payload.html = html;
|
||||||
// If html provided explicitly, set html; if text provided, set text
|
if (text != null) payload.text = text;
|
||||||
if (html != null) payload.html = html;
|
if (message != null) payload.message = message;
|
||||||
if (text != null) payload.text = text;
|
|
||||||
}
|
|
||||||
patch.payload = payload;
|
patch.payload = payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,22 @@ function parseFreeformPhones(lines) {
|
|||||||
return recipients;
|
return recipients;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Human-readable summary of who a WhatsApp broadcast will go to, for the scheduled-jobs admin UI
|
||||||
|
async function describeBroadcastRecipients({ userIds, phones }) {
|
||||||
|
const parts = [];
|
||||||
|
try {
|
||||||
|
const ids = Array.isArray(userIds) ? userIds.filter(x => typeof x === 'string' && x) : [];
|
||||||
|
if (ids.length) {
|
||||||
|
const users = await prisma.user.findMany({ where: { id: { in: ids } }, select: { name: true } });
|
||||||
|
const names = users.map(u => u.name).filter(Boolean);
|
||||||
|
parts.push(names.slice(0, 3).join(', ') + (names.length > 3 ? ` +${names.length - 3} more` : ''));
|
||||||
|
}
|
||||||
|
const extra = parseFreeformPhones(phones);
|
||||||
|
if (extra.length) parts.push(`${extra.length} phone number${extra.length === 1 ? '' : 's'}`);
|
||||||
|
} catch {}
|
||||||
|
return parts.length ? parts.join('; ') : 'No recipients';
|
||||||
|
}
|
||||||
|
|
||||||
// @desc Preview WhatsApp broadcast recipients
|
// @desc Preview WhatsApp broadcast recipients
|
||||||
// @route POST /api/whatsapp-broadcasts/preview
|
// @route POST /api/whatsapp-broadcasts/preview
|
||||||
// @access Private/Supervisor or Admin
|
// @access Private/Supervisor or Admin
|
||||||
@@ -152,12 +168,15 @@ const scheduleWhatsAppBroadcast = async (req, res) => {
|
|||||||
|
|
||||||
const payload = { message, userIds, phones, eventId };
|
const payload = { message, userIds, phones, eventId };
|
||||||
|
|
||||||
|
const recipientSummary = await describeBroadcastRecipients({ userIds, phones });
|
||||||
|
|
||||||
const { addJob } = require('../utils/scheduledEmails');
|
const { addJob } = require('../utils/scheduledEmails');
|
||||||
const created = addJob({
|
const created = addJob({
|
||||||
broadcast: true,
|
broadcast: true,
|
||||||
channel: 'whatsapp',
|
channel: 'whatsapp',
|
||||||
scheduledAt: when.toISOString(),
|
scheduledAt: when.toISOString(),
|
||||||
createdById: req.user?.id || null,
|
createdById: req.user?.id || null,
|
||||||
|
recipientSummary,
|
||||||
payload,
|
payload,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1192,13 +1192,14 @@ app.listen(PORT, () => {
|
|||||||
try {
|
try {
|
||||||
const enabled = String(process.env.SCHEDULED_EMAILS_ENABLED || 'true').toLowerCase() !== 'false';
|
const enabled = String(process.env.SCHEDULED_EMAILS_ENABLED || 'true').toLowerCase() !== 'false';
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
const { getDueJobs, updateJob } = require('./utils/scheduledEmails');
|
const { getDueJobs, updateJob, purgeSentJobs } = require('./utils/scheduledEmails');
|
||||||
const { emailEventAttendees, whatsappEventAttendees } = require('./controllers/eventController');
|
const { emailEventAttendees, whatsappEventAttendees } = require('./controllers/eventController');
|
||||||
const { sendBroadcast } = require('./controllers/broadcastController');
|
const { sendBroadcast } = require('./controllers/broadcastController');
|
||||||
const { sendWhatsAppBroadcast } = require('./controllers/whatsappBroadcastController');
|
const { sendWhatsAppBroadcast } = require('./controllers/whatsappBroadcastController');
|
||||||
const intervalMs = parseInt(process.env.SCHEDULED_EMAILS_INTERVAL_MS || '30000', 10);
|
const intervalMs = parseInt(process.env.SCHEDULED_EMAILS_INTERVAL_MS || '30000', 10);
|
||||||
setInterval(async () => {
|
setInterval(async () => {
|
||||||
try {
|
try {
|
||||||
|
try { purgeSentJobs(24 * 60 * 60 * 1000); } catch (e) { console.warn('[scheduled emails] purge failed:', e?.message || e); }
|
||||||
const due = getDueJobs(new Date());
|
const due = getDueJobs(new Date());
|
||||||
if (!due || due.length === 0) return;
|
if (!due || due.length === 0) return;
|
||||||
for (const job of due) {
|
for (const job of due) {
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ function addJob(job) {
|
|||||||
id,
|
id,
|
||||||
eventId: job.eventId || null,
|
eventId: job.eventId || null,
|
||||||
broadcast: !!job.broadcast,
|
broadcast: !!job.broadcast,
|
||||||
|
channel: job.channel || 'email',
|
||||||
|
recipientSummary: job.recipientSummary || null,
|
||||||
createdById: job.createdById || null,
|
createdById: job.createdById || null,
|
||||||
scheduledAt: job.scheduledAt,
|
scheduledAt: job.scheduledAt,
|
||||||
createdAt: now.toISOString(),
|
createdAt: now.toISOString(),
|
||||||
@@ -66,10 +68,26 @@ function listJobs(filter = {}) {
|
|||||||
return jobs.filter(j => {
|
return jobs.filter(j => {
|
||||||
if (filter.status && j.status !== filter.status) return false;
|
if (filter.status && j.status !== filter.status) return false;
|
||||||
if (filter.eventId && j.eventId !== filter.eventId) return false;
|
if (filter.eventId && j.eventId !== filter.eventId) return false;
|
||||||
|
if (filter.channel && (j.channel || 'email') !== filter.channel) return false;
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permanently remove jobs that finished sending more than maxAgeMs ago.
|
||||||
|
* Only touches 'sent' jobs - queued/sending/error jobs are left for admins to review.
|
||||||
|
*/
|
||||||
|
function purgeSentJobs(maxAgeMs = 24 * 60 * 60 * 1000) {
|
||||||
|
const jobs = loadAll();
|
||||||
|
const now = Date.now();
|
||||||
|
const kept = jobs.filter(j => {
|
||||||
|
if (j.status !== 'sent' || !j.sentAt) return true;
|
||||||
|
return (now - new Date(j.sentAt).getTime()) <= maxAgeMs;
|
||||||
|
});
|
||||||
|
if (kept.length !== jobs.length) saveAll(kept);
|
||||||
|
return jobs.length - kept.length;
|
||||||
|
}
|
||||||
|
|
||||||
function getDueJobs(now = new Date()) {
|
function getDueJobs(now = new Date()) {
|
||||||
const jobs = loadAll();
|
const jobs = loadAll();
|
||||||
const t = now instanceof Date ? now : new Date(now);
|
const t = now instanceof Date ? now : new Date(now);
|
||||||
@@ -105,4 +123,5 @@ module.exports = {
|
|||||||
updateJob,
|
updateJob,
|
||||||
getJob,
|
getJob,
|
||||||
deleteJob,
|
deleteJob,
|
||||||
|
purgeSentJobs,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hope-events-frontend",
|
"name": "hope-events-frontend",
|
||||||
"version": "1.5.0",
|
"version": "1.5.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "next dev --turbopack",
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useAuth } from "@/hooks/useAuth";
|
|||||||
import { apiFetch } from "@/lib/api";
|
import { apiFetch } from "@/lib/api";
|
||||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||||
import type { EventCost, EventCostType, EventFinancials, CashupMethod } from "@/types";
|
import type { EventCost, EventCostType, EventFinancials, CashupMethod } from "@/types";
|
||||||
import { Wallet } from "lucide-react";
|
import { Wallet, DollarSign, ClipboardCheck, FileBarChart } from "lucide-react";
|
||||||
|
|
||||||
const METHOD_LABELS: Record<CashupMethod, string> = { cash: "Cash", card: "Card", eft: "EFT", other: "Other" };
|
const METHOD_LABELS: Record<CashupMethod, string> = { cash: "Cash", card: "Card", eft: "EFT", other: "Other" };
|
||||||
const METHODS: CashupMethod[] = ["cash", "card", "eft", "other"];
|
const METHODS: CashupMethod[] = ["cash", "card", "eft", "other"];
|
||||||
@@ -76,9 +76,9 @@ export default function EventCashupPage() {
|
|||||||
{error && <div className="text-sm text-red-600 bg-red-50 border border-red-100 rounded p-2">{error}</div>}
|
{error && <div className="text-sm text-red-600 bg-red-50 border border-red-100 rounded p-2">{error}</div>}
|
||||||
|
|
||||||
<div className="flex gap-2 border-b">
|
<div className="flex gap-2 border-b">
|
||||||
<button className={"px-3 py-2 text-sm " + (tab === "costs" ? "border-b-2 border-brand-600 text-brand-700 font-medium" : "text-gray-500")} onClick={() => setTab("costs")}>Costs</button>
|
<button className={"inline-flex items-center gap-1.5 px-3 py-2 text-sm " + (tab === "costs" ? "border-b-2 border-brand-600 text-brand-700 font-medium" : "text-gray-500")} onClick={() => setTab("costs")}><DollarSign className="w-4 h-4" />Costs</button>
|
||||||
<button className={"px-3 py-2 text-sm " + (tab === "reconciliation" ? "border-b-2 border-brand-600 text-brand-700 font-medium" : "text-gray-500")} onClick={() => setTab("reconciliation")}>Reconciliation</button>
|
<button className={"inline-flex items-center gap-1.5 px-3 py-2 text-sm " + (tab === "reconciliation" ? "border-b-2 border-brand-600 text-brand-700 font-medium" : "text-gray-500")} onClick={() => setTab("reconciliation")}><ClipboardCheck className="w-4 h-4" />Reconciliation</button>
|
||||||
<button className={"px-3 py-2 text-sm " + (tab === "report" ? "border-b-2 border-brand-600 text-brand-700 font-medium" : "text-gray-500")} onClick={() => setTab("report")}>Report</button>
|
<button className={"inline-flex items-center gap-1.5 px-3 py-2 text-sm " + (tab === "report" ? "border-b-2 border-brand-600 text-brand-700 font-medium" : "text-gray-500")} onClick={() => setTab("report")}><FileBarChart className="w-4 h-4" />Report</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading && <div className="text-sm text-gray-400">Loading…</div>}
|
{loading && <div className="text-sm text-gray-400">Loading…</div>}
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ export default function AdminDashboardPage() {
|
|||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-6xl mx-auto w-full">
|
<div className="max-w-6xl mx-auto w-full overflow-x-hidden">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h1 className="text-2xl font-semibold text-gray-900">Welcome back{user ? `, ${user.name}` : ""} 👋</h1>
|
<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's what's happening with your events today.</p>
|
<p className="text-sm text-gray-500 mt-1">Here's what's happening with your events today.</p>
|
||||||
@@ -132,7 +132,7 @@ export default function AdminDashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid lg:grid-cols-3 gap-6">
|
<div className="grid lg:grid-cols-3 gap-6">
|
||||||
<div className="lg:col-span-2 space-y-6">
|
<div className="lg:col-span-2 space-y-6 min-w-0">
|
||||||
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
||||||
<h2 className="text-lg font-semibold mb-3">Revenue trend — past month</h2>
|
<h2 className="text-lg font-semibold mb-3">Revenue trend — past month</h2>
|
||||||
{overview && overview.trend.length > 0 ? (
|
{overview && overview.trend.length > 0 ? (
|
||||||
@@ -157,7 +157,7 @@ export default function AdminDashboardPage() {
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{overview.topEvents.map(e => (
|
{overview.topEvents.map(e => (
|
||||||
<TableRow key={e.eventId}>
|
<TableRow key={e.eventId}>
|
||||||
<TableCell className="font-medium">{e.title}</TableCell>
|
<TableCell className="font-medium max-w-[140px] sm:max-w-[220px] truncate" title={e.title}>{e.title}</TableCell>
|
||||||
<TableCell className="text-right">{formatCount(e.registrations)}</TableCell>
|
<TableCell className="text-right">{formatCount(e.registrations)}</TableCell>
|
||||||
<TableCell className="text-right">{formatRand(e.revenue)}</TableCell>
|
<TableCell className="text-right">{formatRand(e.revenue)}</TableCell>
|
||||||
<TableCell className="text-right">{formatCount(e.ticketsSold)}</TableCell>
|
<TableCell className="text-right">{formatCount(e.ticketsSold)}</TableCell>
|
||||||
@@ -171,7 +171,7 @@ export default function AdminDashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6 min-w-0">
|
||||||
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h2 className="text-lg font-semibold">Payments overview</h2>
|
<h2 className="text-lg font-semibold">Payments overview</h2>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useRouter } from "next/navigation";
|
|||||||
import { apiFetch } from "@/lib/api";
|
import { apiFetch } from "@/lib/api";
|
||||||
import { scoreUser } from "@/lib/fuzzyMatch";
|
import { scoreUser } from "@/lib/fuzzyMatch";
|
||||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||||
import { DoorOpen } from "lucide-react";
|
import { DoorOpen, UserPlus, CreditCard, CheckSquare, Ticket, RotateCcw, type LucideIcon } from "lucide-react";
|
||||||
|
|
||||||
type Mode = "registration" | "payment" | "checkin" | "tickets" | "refund";
|
type Mode = "registration" | "payment" | "checkin" | "tickets" | "refund";
|
||||||
|
|
||||||
@@ -18,6 +18,14 @@ const MODE_LABELS: Record<Mode, string> = {
|
|||||||
refund: "REFUND",
|
refund: "REFUND",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const MODE_ICONS: Record<Mode, LucideIcon> = {
|
||||||
|
registration: UserPlus,
|
||||||
|
payment: CreditCard,
|
||||||
|
checkin: CheckSquare,
|
||||||
|
tickets: Ticket,
|
||||||
|
refund: RotateCcw,
|
||||||
|
};
|
||||||
|
|
||||||
// ─── Fuzzy search helpers ─────────────────────────────────────────────────────
|
// ─── Fuzzy search helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
function fuzzyFilterRegs(allRegs: any[], search: string): any[] {
|
function fuzzyFilterRegs(allRegs: any[], search: string): any[] {
|
||||||
@@ -390,21 +398,25 @@ export default function AtTheDoorPage() {
|
|||||||
|
|
||||||
{/* Mode Buttons */}
|
{/* Mode Buttons */}
|
||||||
<div className="flex gap-2 mb-4 flex-wrap">
|
<div className="flex gap-2 mb-4 flex-wrap">
|
||||||
{(["registration", "payment", "checkin", "tickets", "refund"] as Mode[]).map(m => (
|
{(["registration", "payment", "checkin", "tickets", "refund"] as Mode[]).map(m => {
|
||||||
<button
|
const Icon = MODE_ICONS[m];
|
||||||
key={m}
|
return (
|
||||||
onClick={() => setMode(m)}
|
<button
|
||||||
className={`px-4 py-2 rounded text-sm font-medium border ${
|
key={m}
|
||||||
mode === m
|
onClick={() => setMode(m)}
|
||||||
? m === "refund"
|
className={`inline-flex items-center gap-1.5 px-4 py-2 rounded text-sm font-medium border ${
|
||||||
? "bg-red-600 text-white border-red-600"
|
mode === m
|
||||||
: "bg-brand-600 text-white border-brand-600"
|
? m === "refund"
|
||||||
: "bg-white hover:bg-gray-50"
|
? "bg-red-600 text-white border-red-600"
|
||||||
}`}
|
: "bg-brand-600 text-white border-brand-600"
|
||||||
>
|
: "bg-white hover:bg-gray-50"
|
||||||
{MODE_LABELS[m]}
|
}`}
|
||||||
</button>
|
>
|
||||||
))}
|
<Icon className="w-4 h-4" />
|
||||||
|
{MODE_LABELS[m]}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ✅ Panels */}
|
{/* ✅ Panels */}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useAuth } from "@/hooks/useAuth";
|
|||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { apiFetch, fetchAllUsers } from "@/lib/api";
|
import { apiFetch, fetchAllUsers } from "@/lib/api";
|
||||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||||
import { Mail } from "lucide-react";
|
import { Mail, Users, Zap, Clock } from "lucide-react";
|
||||||
|
|
||||||
type Attendee = { id: string; name: string; email: string; pref: string };
|
type Attendee = { id: string; name: string; email: string; pref: string };
|
||||||
|
|
||||||
@@ -261,7 +261,7 @@ function EmailAttendeesPageInner() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Scheduled jobs state
|
// Scheduled jobs state
|
||||||
type ScheduledJob = { id: string; kind: 'attendees'|'broadcast'|'unknown'; eventId?: string|null; broadcast?: boolean; scheduledAt: string; createdAt: string; status: 'queued'|'sending'|'sent'|'error'; attempts: number; sentAt?: string|null; lastError?: string|null; subject?: string; hasHtml?: boolean; hasText?: boolean };
|
type ScheduledJob = { id: string; kind: 'attendees'|'broadcast'|'unknown'; eventId?: string|null; broadcast?: boolean; channel?: string; recipient?: string|null; scheduledAt: string; createdAt: string; status: 'queued'|'sending'|'sent'|'error'; attempts: number; sentAt?: string|null; lastError?: string|null; subject?: string; html?: string; text?: string; hasHtml?: boolean; hasText?: boolean };
|
||||||
const [scheduled, setScheduled] = useState<ScheduledJob[]>([]);
|
const [scheduled, setScheduled] = useState<ScheduledJob[]>([]);
|
||||||
const [loadingScheduled, setLoadingScheduled] = useState(false);
|
const [loadingScheduled, setLoadingScheduled] = useState(false);
|
||||||
const [editing, setEditing] = useState<ScheduledJob | null>(null);
|
const [editing, setEditing] = useState<ScheduledJob | null>(null);
|
||||||
@@ -274,7 +274,7 @@ function EmailAttendeesPageInner() {
|
|||||||
try {
|
try {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
setLoadingScheduled(true);
|
setLoadingScheduled(true);
|
||||||
const res = await apiFetch<{jobs: ScheduledJob[]}>(`/api/scheduled-emails`, { authToken: token });
|
const res = await apiFetch<{jobs: ScheduledJob[]}>(`/api/scheduled-emails?channel=email`, { authToken: token });
|
||||||
setScheduled(Array.isArray(res?.jobs) ? res.jobs : []);
|
setScheduled(Array.isArray(res?.jobs) ? res.jobs : []);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// ignore here; surfaces via UI when tab open
|
// ignore here; surfaces via UI when tab open
|
||||||
@@ -288,7 +288,7 @@ function EmailAttendeesPageInner() {
|
|||||||
const openEdit = (job: ScheduledJob) => {
|
const openEdit = (job: ScheduledJob) => {
|
||||||
setEditing(job);
|
setEditing(job);
|
||||||
setEditSubject(job.subject || '');
|
setEditSubject(job.subject || '');
|
||||||
setEditBody(''); // body not included in list; will let user set a new one if needed
|
setEditBody(job.html || job.text || '');
|
||||||
try { setEditWhen(toLocalInputValue(new Date(job.scheduledAt))); } catch { setEditWhen(''); }
|
try { setEditWhen(toLocalInputValue(new Date(job.scheduledAt))); } catch { setEditWhen(''); }
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -503,20 +503,24 @@ function EmailAttendeesPageInner() {
|
|||||||
|
|
||||||
{/* Tabs like on Payments page */}
|
{/* Tabs like on Payments page */}
|
||||||
<div className="mb-4 flex items-center gap-2 flex-wrap">
|
<div className="mb-4 flex items-center gap-2 flex-wrap">
|
||||||
<label className={`px-3 py-1.5 text-sm rounded border ${tab === 'attendees' ? 'bg-brand-600 text-white border-brand-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
|
<label className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded border ${tab === 'attendees' ? 'bg-brand-600 text-white border-brand-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
|
||||||
<input type="radio" name="emailTab" value="attendees" className="hidden" checked={tab==='attendees'} onChange={() => setTab('attendees')} />
|
<input type="radio" name="emailTab" value="attendees" className="hidden" checked={tab==='attendees'} onChange={() => setTab('attendees')} />
|
||||||
|
<Users className="w-4 h-4" />
|
||||||
Attendees
|
Attendees
|
||||||
</label>
|
</label>
|
||||||
<label className={`px-3 py-1.5 text-sm rounded border ${tab === 'automations' ? 'bg-brand-600 text-white border-brand-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
|
<label className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded border ${tab === 'automations' ? 'bg-brand-600 text-white border-brand-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
|
||||||
<input type="radio" name="emailTab" value="automations" className="hidden" checked={tab==='automations'} onChange={() => setTab('automations')} />
|
<input type="radio" name="emailTab" value="automations" className="hidden" checked={tab==='automations'} onChange={() => setTab('automations')} />
|
||||||
|
<Zap className="w-4 h-4" />
|
||||||
Automations
|
Automations
|
||||||
</label>
|
</label>
|
||||||
<label className={`px-3 py-1.5 text-sm rounded border ${tab === 'broadcasts' ? 'bg-brand-600 text-white border-brand-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
|
<label className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded border ${tab === 'broadcasts' ? 'bg-brand-600 text-white border-brand-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
|
||||||
<input type="radio" name="emailTab" value="broadcasts" className="hidden" checked={tab==='broadcasts'} onChange={() => setTab('broadcasts')} />
|
<input type="radio" name="emailTab" value="broadcasts" className="hidden" checked={tab==='broadcasts'} onChange={() => setTab('broadcasts')} />
|
||||||
|
<Mail className="w-4 h-4" />
|
||||||
Broadcasts
|
Broadcasts
|
||||||
</label>
|
</label>
|
||||||
<label className={`px-3 py-1.5 text-sm rounded border ${tab === 'scheduled' ? 'bg-brand-600 text-white border-brand-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
|
<label className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded border ${tab === 'scheduled' ? 'bg-brand-600 text-white border-brand-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
|
||||||
<input type="radio" name="emailTab" value="scheduled" className="hidden" checked={tab==='scheduled'} onChange={() => setTab('scheduled')} />
|
<input type="radio" name="emailTab" value="scheduled" className="hidden" checked={tab==='scheduled'} onChange={() => setTab('scheduled')} />
|
||||||
|
<Clock className="w-4 h-4" />
|
||||||
Scheduled
|
Scheduled
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -872,7 +876,7 @@ Jane Doe <jane@example.com>
|
|||||||
{loadingScheduled ? (
|
{loadingScheduled ? (
|
||||||
<div className="text-sm text-gray-600">Loading…</div>
|
<div className="text-sm text-gray-600">Loading…</div>
|
||||||
) : scheduled.length === 0 ? (
|
) : scheduled.length === 0 ? (
|
||||||
<div className="text-sm text-gray-600">No scheduled items. Items sent more than a week ago are hidden.</div>
|
<div className="text-sm text-gray-600">No scheduled emails. Sent items are cleared 24 hours after sending.</div>
|
||||||
) : (
|
) : (
|
||||||
<ul className="divide-y border rounded">
|
<ul className="divide-y border rounded">
|
||||||
{scheduled.map(job => (
|
{scheduled.map(job => (
|
||||||
@@ -882,6 +886,16 @@ Jane Doe <jane@example.com>
|
|||||||
<span className="inline-block px-2 py-0.5 text-xs rounded border bg-gray-50">{job.kind}</span>
|
<span className="inline-block px-2 py-0.5 text-xs rounded border bg-gray-50">{job.kind}</span>
|
||||||
<span className="truncate">{job.subject || '(no subject)'}</span>
|
<span className="truncate">{job.subject || '(no subject)'}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="text-xs text-gray-600 mt-1 truncate">
|
||||||
|
{(() => {
|
||||||
|
const body = (job.html || job.text || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
|
||||||
|
if (!body) return '(no content)';
|
||||||
|
return body.length > 80 ? body.slice(0, 80) + '…' : body;
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-600 mt-1">
|
||||||
|
<span className="mr-2">To: {job.recipient || 'Unknown recipients'}</span>
|
||||||
|
</div>
|
||||||
<div className="text-xs text-gray-600 mt-1">
|
<div className="text-xs text-gray-600 mt-1">
|
||||||
<span className="mr-2">Status: {job.status}</span>
|
<span className="mr-2">Status: {job.status}</span>
|
||||||
<span className="mr-2">Scheduled: {(() => { try { return new Date(job.scheduledAt).toLocaleString(); } catch { return job.scheduledAt; } })()}</span>
|
<span className="mr-2">Scheduled: {(() => { try { return new Date(job.scheduledAt).toLocaleString(); } catch { return job.scheduledAt; } })()}</span>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useRouter } from "next/navigation";
|
|||||||
import { apiFetch, fetchAllUsers } from "@/lib/api";
|
import { apiFetch, fetchAllUsers } from "@/lib/api";
|
||||||
import { scoreUser } from "@/lib/fuzzyMatch";
|
import { scoreUser } from "@/lib/fuzzyMatch";
|
||||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||||
import { UserPlus } from "lucide-react";
|
import { UserPlus, CreditCard } from "lucide-react";
|
||||||
|
|
||||||
// ─── Pricing helpers ─────────────────────────────────────────────────────────
|
// ─── Pricing helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -355,12 +355,14 @@ export default function ManualRegistrationPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mb-4 flex items-center gap-2">
|
<div className="mb-4 flex items-center gap-2">
|
||||||
<label className={`px-3 py-1.5 text-sm rounded border ${tab === 'register' ? 'bg-brand-600 text-white border-brand-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
|
<label className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded border ${tab === 'register' ? 'bg-brand-600 text-white border-brand-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
|
||||||
<input type="radio" name="tab" value="register" className="hidden" checked={tab==='register'} onChange={() => setTab('register')} />
|
<input type="radio" name="tab" value="register" className="hidden" checked={tab==='register'} onChange={() => setTab('register')} />
|
||||||
|
<UserPlus className="w-4 h-4" />
|
||||||
Register
|
Register
|
||||||
</label>
|
</label>
|
||||||
<label className={`px-3 py-1.5 text-sm rounded border ${tab === 'payment' ? 'bg-brand-600 text-white border-brand-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
|
<label className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded border ${tab === 'payment' ? 'bg-brand-600 text-white border-brand-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
|
||||||
<input type="radio" name="tab" value="payment" className="hidden" checked={tab==='payment'} onChange={() => setTab('payment')} />
|
<input type="radio" name="tab" value="payment" className="hidden" checked={tab==='payment'} onChange={() => setTab('payment')} />
|
||||||
|
<CreditCard className="w-4 h-4" />
|
||||||
Record Payment
|
Record Payment
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ export default function SupervisorDashboardPage() {
|
|||||||
}, 15000, !!token);
|
}, 15000, !!token);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-6xl mx-auto w-full">
|
<div className="max-w-6xl mx-auto w-full overflow-x-hidden">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h1 className="text-2xl font-semibold text-gray-900">Welcome back{user ? `, ${user.name}` : ""} 👋</h1>
|
<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's what's happening with your events today.</p>
|
<p className="text-sm text-gray-500 mt-1">Here's what's happening with your events today.</p>
|
||||||
@@ -131,7 +131,7 @@ export default function SupervisorDashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid lg:grid-cols-3 gap-6">
|
<div className="grid lg:grid-cols-3 gap-6">
|
||||||
<div className="lg:col-span-2 space-y-6">
|
<div className="lg:col-span-2 space-y-6 min-w-0">
|
||||||
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
||||||
<h2 className="text-lg font-semibold mb-3">Revenue trend — past month</h2>
|
<h2 className="text-lg font-semibold mb-3">Revenue trend — past month</h2>
|
||||||
{overview && overview.trend.length > 0 ? (
|
{overview && overview.trend.length > 0 ? (
|
||||||
@@ -156,7 +156,7 @@ export default function SupervisorDashboardPage() {
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{overview.topEvents.map(e => (
|
{overview.topEvents.map(e => (
|
||||||
<TableRow key={e.eventId}>
|
<TableRow key={e.eventId}>
|
||||||
<TableCell className="font-medium">{e.title}</TableCell>
|
<TableCell className="font-medium max-w-[140px] sm:max-w-[220px] truncate" title={e.title}>{e.title}</TableCell>
|
||||||
<TableCell className="text-right">{formatCount(e.registrations)}</TableCell>
|
<TableCell className="text-right">{formatCount(e.registrations)}</TableCell>
|
||||||
<TableCell className="text-right">{formatRand(e.revenue)}</TableCell>
|
<TableCell className="text-right">{formatRand(e.revenue)}</TableCell>
|
||||||
<TableCell className="text-right">{formatCount(e.ticketsSold)}</TableCell>
|
<TableCell className="text-right">{formatCount(e.ticketsSold)}</TableCell>
|
||||||
@@ -190,7 +190,7 @@ export default function SupervisorDashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6 min-w-0">
|
||||||
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h2 className="text-lg font-semibold">Scanner activity</h2>
|
<h2 className="text-lg font-semibold">Scanner activity</h2>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useRouter, useSearchParams } from "next/navigation";
|
|||||||
import { apiFetch, fetchAllUsers, fetchAllPayments } from "@/lib/api";
|
import { apiFetch, fetchAllUsers, fetchAllPayments } from "@/lib/api";
|
||||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||||
import { scoreUser } from "@/lib/fuzzyMatch";
|
import { scoreUser } from "@/lib/fuzzyMatch";
|
||||||
import { Wallet } from "lucide-react";
|
import { Wallet, RotateCcw, HandHeart, ScanLine, Link2 } from "lucide-react";
|
||||||
|
|
||||||
// A donation is never mutated once created — assigning it to a registration creates a separate
|
// A donation is never mutated once created — assigning it to a registration creates a separate
|
||||||
// "leg" Payment row (isDonation:false, originalPaymentId -> the donation, amount > 0) instead.
|
// "leg" Payment row (isDonation:false, originalPaymentId -> the donation, amount > 0) instead.
|
||||||
@@ -566,24 +566,29 @@ function PaymentsContent() {
|
|||||||
{info && <div className="p-3 mb-3 border rounded bg-emerald-50 text-emerald-800 text-sm">{info}</div>}
|
{info && <div className="p-3 mb-3 border rounded bg-emerald-50 text-emerald-800 text-sm">{info}</div>}
|
||||||
|
|
||||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||||
<label className={`px-3 py-1.5 text-sm rounded border ${mode === 'payment' ? 'bg-brand-600 text-white border-brand-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
|
<label className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded border ${mode === 'payment' ? 'bg-brand-600 text-white border-brand-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
|
||||||
<input type="radio" name="mode" value="payment" className="hidden" checked={mode==='payment'} onChange={() => setMode('payment')} />
|
<input type="radio" name="mode" value="payment" className="hidden" checked={mode==='payment'} onChange={() => setMode('payment')} />
|
||||||
|
<Wallet className="w-4 h-4" />
|
||||||
Payment
|
Payment
|
||||||
</label>
|
</label>
|
||||||
<label className={`px-3 py-1.5 text-sm rounded border ${mode === 'refund' ? 'bg-brand-600 text-white border-brand-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
|
<label className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded border ${mode === 'refund' ? 'bg-brand-600 text-white border-brand-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
|
||||||
<input type="radio" name="mode" value="refund" className="hidden" checked={mode==='refund'} onChange={() => setMode('refund')} />
|
<input type="radio" name="mode" value="refund" className="hidden" checked={mode==='refund'} onChange={() => setMode('refund')} />
|
||||||
|
<RotateCcw className="w-4 h-4" />
|
||||||
Refund
|
Refund
|
||||||
</label>
|
</label>
|
||||||
<label className={`px-3 py-1.5 text-sm rounded border ${mode === 'donation' ? 'bg-brand-600 text-white border-brand-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
|
<label className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded border ${mode === 'donation' ? 'bg-brand-600 text-white border-brand-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
|
||||||
<input type="radio" name="mode" value="donation" className="hidden" checked={mode==='donation'} onChange={() => setMode('donation')} />
|
<input type="radio" name="mode" value="donation" className="hidden" checked={mode==='donation'} onChange={() => setMode('donation')} />
|
||||||
|
<HandHeart className="w-4 h-4" />
|
||||||
Donations
|
Donations
|
||||||
</label>
|
</label>
|
||||||
<label className={`px-3 py-1.5 text-sm rounded border ${mode === 'reconcile' ? 'bg-brand-600 text-white border-brand-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
|
<label className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded border ${mode === 'reconcile' ? 'bg-brand-600 text-white border-brand-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
|
||||||
<input type="radio" name="mode" value="reconcile" className="hidden" checked={mode==='reconcile'} onChange={() => setMode('reconcile')} />
|
<input type="radio" name="mode" value="reconcile" className="hidden" checked={mode==='reconcile'} onChange={() => setMode('reconcile')} />
|
||||||
|
<ScanLine className="w-4 h-4" />
|
||||||
Reconcile
|
Reconcile
|
||||||
</label>
|
</label>
|
||||||
<label className={`px-3 py-1.5 text-sm rounded border ${mode === 'link' ? 'bg-brand-600 text-white border-brand-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
|
<label className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded border ${mode === 'link' ? 'bg-brand-600 text-white border-brand-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
|
||||||
<input type="radio" name="mode" value="link" className="hidden" checked={mode==='link'} onChange={() => setMode('link')} />
|
<input type="radio" name="mode" value="link" className="hidden" checked={mode==='link'} onChange={() => setMode('link')} />
|
||||||
|
<Link2 className="w-4 h-4" />
|
||||||
Payment Link
|
Payment Link
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,12 +5,19 @@ import { useAuth } from "@/hooks/useAuth";
|
|||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { apiFetch, fetchAllUsers } from "@/lib/api";
|
import { apiFetch, fetchAllUsers } from "@/lib/api";
|
||||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||||
import { MessageCircle } from "lucide-react";
|
import { MessageCircle, Users, Zap, Send, Clock, type LucideIcon } from "lucide-react";
|
||||||
|
|
||||||
// Attendee with preference info
|
// Attendee with preference info
|
||||||
type Attendee = { id: string; name: string; phone: string; pref: string };
|
type Attendee = { id: string; name: string; phone: string; pref: string };
|
||||||
type UserEntry = { id: string; name: string; phone: string; pref: string };
|
type UserEntry = { id: string; name: string; phone: string; pref: string };
|
||||||
|
|
||||||
|
const WA_TAB_ICONS: Record<"attendees" | "automations" | "broadcasts" | "scheduled", LucideIcon> = {
|
||||||
|
attendees: Users,
|
||||||
|
automations: Zap,
|
||||||
|
broadcasts: Send,
|
||||||
|
scheduled: Clock,
|
||||||
|
};
|
||||||
|
|
||||||
function toLocalInputValue(d: Date) {
|
function toLocalInputValue(d: Date) {
|
||||||
const pad = (n: number) => String(n).padStart(2, "0");
|
const pad = (n: number) => String(n).padStart(2, "0");
|
||||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
@@ -535,7 +542,7 @@ function WhatsAppAttendeesPageInner() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ── Scheduled tab ──────────────────────────────────────────────────────────
|
// ── Scheduled tab ──────────────────────────────────────────────────────────
|
||||||
type ScheduledJob = { id: string; kind: string; eventId?: string | null; broadcast?: boolean; channel?: string; scheduledAt: string; createdAt: string; status: string; attempts: number; sentAt?: string | null; lastError?: string | null; subject?: string; payload?: any };
|
type ScheduledJob = { id: string; kind: string; eventId?: string | null; broadcast?: boolean; channel?: string; recipient?: string | null; scheduledAt: string; createdAt: string; status: string; attempts: number; sentAt?: string | null; lastError?: string | null; subject?: string; message?: string };
|
||||||
const [scheduled, setScheduled] = useState<ScheduledJob[]>([]);
|
const [scheduled, setScheduled] = useState<ScheduledJob[]>([]);
|
||||||
const [loadingScheduled, setLoadingScheduled] = useState(false);
|
const [loadingScheduled, setLoadingScheduled] = useState(false);
|
||||||
const [editing, setEditing] = useState<ScheduledJob | null>(null);
|
const [editing, setEditing] = useState<ScheduledJob | null>(null);
|
||||||
@@ -547,10 +554,8 @@ function WhatsAppAttendeesPageInner() {
|
|||||||
try {
|
try {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
setLoadingScheduled(true);
|
setLoadingScheduled(true);
|
||||||
const res = await apiFetch<{ jobs: ScheduledJob[] }>(`/api/scheduled-emails`, { authToken: token });
|
const res = await apiFetch<{ jobs: ScheduledJob[] }>(`/api/scheduled-emails?channel=whatsapp`, { authToken: token });
|
||||||
// Filter to only WhatsApp jobs
|
setScheduled(Array.isArray(res?.jobs) ? res.jobs : []);
|
||||||
const all = Array.isArray(res?.jobs) ? res.jobs : [];
|
|
||||||
setScheduled(all.filter((j) => j.channel === "whatsapp" || (j.broadcast && j.channel === "whatsapp")));
|
|
||||||
} catch { } finally { setLoadingScheduled(false); }
|
} catch { } finally { setLoadingScheduled(false); }
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -563,7 +568,7 @@ function WhatsAppAttendeesPageInner() {
|
|||||||
if (!token) { setError("Not authenticated"); return; }
|
if (!token) { setError("Not authenticated"); return; }
|
||||||
const body: any = {};
|
const body: any = {};
|
||||||
if (editWhen) body.scheduledAt = new Date(editWhen).toISOString();
|
if (editWhen) body.scheduledAt = new Date(editWhen).toISOString();
|
||||||
if (editMessage.trim()) body.text = editMessage;
|
if (editMessage.trim()) body.message = editMessage;
|
||||||
await apiFetch(`/api/scheduled-emails/${encodeURIComponent(editing.id)}`, { method: "PATCH", authToken: token, body });
|
await apiFetch(`/api/scheduled-emails/${encodeURIComponent(editing.id)}`, { method: "PATCH", authToken: token, body });
|
||||||
setInfo("Scheduled message updated.");
|
setInfo("Scheduled message updated.");
|
||||||
setEditing(null);
|
setEditing(null);
|
||||||
@@ -614,12 +619,16 @@ function WhatsAppAttendeesPageInner() {
|
|||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="mb-4 flex items-center gap-2 flex-wrap">
|
<div className="mb-4 flex items-center gap-2 flex-wrap">
|
||||||
{(["attendees", "automations", "broadcasts", "scheduled"] as const).map((t) => (
|
{(["attendees", "automations", "broadcasts", "scheduled"] as const).map((t) => {
|
||||||
<label key={t} className={`px-3 py-1.5 text-sm rounded border cursor-pointer ${tab === t ? "bg-green-600 text-white border-green-600" : "bg-white text-gray-800 border-gray-200"}`}>
|
const Icon = WA_TAB_ICONS[t];
|
||||||
<input type="radio" name="waTab" value={t} className="hidden" checked={tab === t} onChange={() => setTab(t)} />
|
return (
|
||||||
{t.charAt(0).toUpperCase() + t.slice(1)}
|
<label key={t} className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded border cursor-pointer ${tab === t ? "bg-green-600 text-white border-green-600" : "bg-white text-gray-800 border-gray-200"}`}>
|
||||||
</label>
|
<input type="radio" name="waTab" value={t} className="hidden" checked={tab === t} onChange={() => setTab(t)} />
|
||||||
))}
|
<Icon className="w-4 h-4" />
|
||||||
|
{t.charAt(0).toUpperCase() + t.slice(1)}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Attendees Tab ── */}
|
{/* ── Attendees Tab ── */}
|
||||||
@@ -882,7 +891,7 @@ function WhatsAppAttendeesPageInner() {
|
|||||||
{loadingScheduled ? (
|
{loadingScheduled ? (
|
||||||
<div className="text-sm text-gray-600">Loading…</div>
|
<div className="text-sm text-gray-600">Loading…</div>
|
||||||
) : scheduled.length === 0 ? (
|
) : scheduled.length === 0 ? (
|
||||||
<div className="text-sm text-gray-600">No scheduled WhatsApp messages. Items sent more than a week ago are hidden.</div>
|
<div className="text-sm text-gray-600">No scheduled WhatsApp messages. Sent items are cleared 24 hours after sending.</div>
|
||||||
) : (
|
) : (
|
||||||
<ul className="divide-y border rounded">
|
<ul className="divide-y border rounded">
|
||||||
{scheduled.map((job) => (
|
{scheduled.map((job) => (
|
||||||
@@ -890,7 +899,10 @@ function WhatsAppAttendeesPageInner() {
|
|||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="text-sm font-medium text-gray-900 flex items-center gap-2">
|
<div className="text-sm font-medium text-gray-900 flex items-center gap-2">
|
||||||
<span className="inline-block px-2 py-0.5 text-xs rounded border bg-green-50 text-green-700">{job.broadcast ? "broadcast" : "attendees"}</span>
|
<span className="inline-block px-2 py-0.5 text-xs rounded border bg-green-50 text-green-700">{job.broadcast ? "broadcast" : "attendees"}</span>
|
||||||
<span className="truncate text-gray-700">{job.payload?.message ? String(job.payload.message).slice(0, 60) + (String(job.payload.message).length > 60 ? "…" : "") : "(no message)"}</span>
|
<span className="truncate text-gray-700">{job.message ? String(job.message).slice(0, 60) + (String(job.message).length > 60 ? "…" : "") : "(no message)"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-600 mt-1">
|
||||||
|
<span className="mr-2">To: {job.recipient || "Unknown recipients"}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-gray-600 mt-1">
|
<div className="text-xs text-gray-600 mt-1">
|
||||||
<span className="mr-2">Status: {job.status}</span>
|
<span className="mr-2">Status: {job.status}</span>
|
||||||
@@ -901,7 +913,7 @@ function WhatsAppAttendeesPageInner() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<button type="button" disabled={job.status !== "queued"} className="px-2 py-1 text-xs rounded border bg-white hover:bg-gray-50 disabled:opacity-50"
|
<button type="button" disabled={job.status !== "queued"} className="px-2 py-1 text-xs rounded border bg-white hover:bg-gray-50 disabled:opacity-50"
|
||||||
onClick={() => { setEditing(job); setEditMessage(job.payload?.message || ""); try { setEditWhen(toLocalInputValue(new Date(job.scheduledAt))); } catch { setEditWhen(""); } }}>
|
onClick={() => { setEditing(job); setEditMessage(job.message || ""); try { setEditWhen(toLocalInputValue(new Date(job.scheduledAt))); } catch { setEditWhen(""); } }}>
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
<button type="button" disabled={job.status !== "queued"} className="px-2 py-1 text-xs rounded border bg-white hover:bg-gray-50 disabled:opacity-50" onClick={() => removeJob(job)}>
|
<button type="button" disabled={job.status !== "queued"} className="px-2 py-1 text-xs rounded border bg-white hover:bg-gray-50 disabled:opacity-50" onClick={() => removeJob(job)}>
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ export const Navbar = () => {
|
|||||||
<button
|
<button
|
||||||
key={link.label}
|
key={link.label}
|
||||||
onClick={link.onClick}
|
onClick={link.onClick}
|
||||||
className="text-sm text-gray-700 hover:text-brand-600"
|
className="text-sm pb-0.5 border-b-2 border-transparent text-gray-700 hover:text-brand-600 transition-colors"
|
||||||
>
|
>
|
||||||
{link.label}
|
{link.label}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -17,10 +17,10 @@ export default function HelpGuideModal({ content, onClose }: { content: HelpCont
|
|||||||
<div className="absolute inset-0 bg-black/40 animate-in fade-in duration-200" onClick={onClose} />
|
<div className="absolute inset-0 bg-black/40 animate-in fade-in duration-200" onClick={onClose} />
|
||||||
<div className="absolute inset-0 flex items-center justify-center p-4">
|
<div className="absolute inset-0 flex items-center justify-center p-4">
|
||||||
<div
|
<div
|
||||||
className="w-full max-w-3xl bg-white rounded-2xl shadow-2xl animate-in fade-in zoom-in-95 slide-in-from-bottom-2 duration-200 overflow-hidden"
|
className="w-full max-w-3xl max-h-full bg-white rounded-2xl shadow-2xl animate-in fade-in zoom-in-95 slide-in-from-bottom-2 duration-200 overflow-hidden flex flex-col"
|
||||||
onClick={e => e.stopPropagation()}
|
onClick={e => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between px-5 py-4 border-b bg-gradient-to-r from-brand-50/60 to-white">
|
<div className="flex items-start justify-between px-5 py-4 border-b bg-gradient-to-r from-brand-50/60 to-white shrink-0">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<div className="w-9 h-9 rounded-full bg-brand-50 flex items-center justify-center shrink-0">
|
<div className="w-9 h-9 rounded-full bg-brand-50 flex items-center justify-center shrink-0">
|
||||||
<MessageCircleQuestion className="w-5 h-5 text-brand-600" />
|
<MessageCircleQuestion className="w-5 h-5 text-brand-600" />
|
||||||
@@ -36,7 +36,7 @@ export default function HelpGuideModal({ content, onClose }: { content: HelpCont
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{content.quickLinks && content.quickLinks.length > 0 && (
|
{content.quickLinks && content.quickLinks.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-2 px-5 py-3 border-b bg-gray-50">
|
<div className="flex flex-wrap gap-2 px-5 py-3 border-b bg-gray-50 shrink-0">
|
||||||
{content.quickLinks.map(link => (
|
{content.quickLinks.map(link => (
|
||||||
<Link
|
<Link
|
||||||
key={link.href}
|
key={link.href}
|
||||||
@@ -51,9 +51,9 @@ export default function HelpGuideModal({ content, onClose }: { content: HelpCont
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row">
|
<div className="flex flex-col sm:flex-row flex-1 min-h-0 overflow-y-auto sm:overflow-visible">
|
||||||
{content.tabs.length > 1 && (
|
{content.tabs.length > 1 && (
|
||||||
<nav className="sm:w-56 shrink-0 border-b sm:border-b-0 sm:border-r p-3 space-y-1 bg-gray-50/50">
|
<nav className="sm:w-56 shrink-0 border-b sm:border-b-0 sm:border-r p-3 space-y-1 bg-gray-50/50 sm:overflow-y-auto">
|
||||||
{content.tabs.map(t => {
|
{content.tabs.map(t => {
|
||||||
const Icon = t.icon;
|
const Icon = t.icon;
|
||||||
const active = activeTab?.key === t.key;
|
const active = activeTab?.key === t.key;
|
||||||
@@ -72,12 +72,12 @@ export default function HelpGuideModal({ content, onClose }: { content: HelpCont
|
|||||||
</nav>
|
</nav>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex-1 min-w-0 p-5 text-sm text-gray-700 max-h-[60vh] overflow-auto">
|
<div className="flex-1 min-w-0 p-5 text-sm text-gray-700 sm:overflow-y-auto">
|
||||||
{activeTab?.content}
|
{activeTab?.content}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t px-5 py-3 space-y-2 bg-gray-50/50">
|
<div className="border-t px-5 py-3 space-y-2 bg-gray-50/50 shrink-0">
|
||||||
{content.supportContact && (
|
{content.supportContact && (
|
||||||
<div className="flex items-center gap-2 text-xs text-gray-500">
|
<div className="flex items-center gap-2 text-xs text-gray-500">
|
||||||
<Mail className="w-3.5 h-3.5 shrink-0" />
|
<Mail className="w-3.5 h-3.5 shrink-0" />
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export function StatCard({
|
|||||||
<Icon className={"w-5 h-5 " + t.icon} />
|
<Icon className={"w-5 h-5 " + t.icon} />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="text-xs text-gray-500 truncate">{label}</div>
|
<div className="text-xs text-gray-500 leading-snug">{label}</div>
|
||||||
<div className="text-xl font-semibold text-gray-900 truncate">{value}</div>
|
<div className="text-xl font-semibold text-gray-900 truncate">{value}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -67,5 +67,5 @@ export function StatCard({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function StatCardRow({ children }: { children: React.ReactNode }) {
|
export function StatCardRow({ children }: { children: React.ReactNode }) {
|
||||||
return <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4">{children}</div>;
|
return <div className="grid grid-cols-1 sm:grid-cols-3 lg:grid-cols-5 gap-4">{children}</div>;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hope-events",
|
"name": "hope-events",
|
||||||
"version": "1.5.0",
|
"version": "1.5.3",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev:backend": "cd backend && npm run dev",
|
"dev:backend": "cd backend && npm run dev",
|
||||||
|
|||||||
Reference in New Issue
Block a user