Fix financial double-counting, rebuild cashup accountability, and redesign the Reports page

Financial correctness (donation-leg model):
- Donations are no longer mutated when assigned to a registration; assignment now
  creates an immutable "leg" record referencing the original donation instead.
- Fixed several places where money was double-counted once a donation was partially
  or fully assigned (Payments, Revenue summary, Cashup reconciliation, Finance
  report, Profit report, Master Orders, Revenue Detailed).
- Payments now record who recorded them (recordedBy), separate from who they're for.

Cashup:
- Per-user cash denomination counting (optional, any time) replaces the single
  event-wide manual entry; the event's cash actual is the live sum of these counts.
- New "Payment accountability by staff member" breakdown across all methods, and a
  read-only "Report" tab that opens automatically once an event is closed.

Reports page redesign:
- New shell: sidebar of universal filters (events, date range, past/inactive/closed
  toggles), searchable/categorized report grid, and a popup viewer with
  Print/Email/Excel/WhatsApp actions plus an in-app Reporting Guide.
- Visual pass: colored stat tiles and bar charts on most reports, matching mockups.
- PDF exports (download/Print/Email/WhatsApp) now share a branded design mirroring
  the web report — colored header, stat tiles, bar chart, highlighted totals.
- Excel export now produces a styled .xlsx (via exceljs) instead of a plain CSV.
- Master Orders' "Donations made" table is now included in every export channel.

Bug fixes discovered while testing exports:
- Report emails now go through the shared, DB-configurable mail utility instead of
  a one-off transporter that ignored Site Settings SMTP config.
- WhatsApp report sends now surface the actual WAWP API error and auto-recover a
  disconnected session, instead of a bare axios status-code message.

Also: Admin-editable notification preference, richer Admin Registrations dashboard,
{{payment.link}} placeholder for Email/WhatsApp Attendees, and background
email/WhatsApp attendee sending.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 14:52:54 +02:00
co-authored by Claude Sonnet 5
parent 56f2a9f7fc
commit 0de3f4be7d
42 changed files with 4297 additions and 1586 deletions
+83 -47
View File
@@ -12,6 +12,7 @@ interface UserItem {
email: string;
role: string;
phoneNumber?: string | null;
notificationPreference?: string;
isActive: boolean;
createdAt: string;
updatedAt: string;
@@ -20,6 +21,8 @@ interface UserItem {
const roleOptions = ["user", "staff", "supervisor", "admin"] as const;
type Role = typeof roleOptions[number];
const notificationPreferenceOptions = ["email", "whatsapp", "both"] as const;
// Simple fuzzy: tolerate one missing/swapped char by checking if query chars appear in order
function fuzzyMatch(query: string, target: string): boolean {
const q = query.toLowerCase();
@@ -71,10 +74,11 @@ export default function AdminUsersPage() {
const [cRole, setCRole] = useState<Role>("user");
const [creating, setCreating] = useState(false);
// Inline edit state
// Edit modal state
const [editingId, setEditingId] = useState<string | null>(null);
const [editData, setEditData] = useState<Partial<UserItem> & { password?: string }>({});
const [saving, setSaving] = useState(false);
const editingUser = useMemo(() => users.find(u => u.id === editingId) || null, [users, editingId]);
const buildQuery = useCallback((p: number, ps = pageSize) => {
const qs = new URLSearchParams({ page: String(p), limit: String(ps) });
@@ -152,6 +156,7 @@ export default function AdminUsersPage() {
email: editData.email,
role: editData.role,
phoneNumber: editData.phoneNumber || null,
notificationPreference: editData.notificationPreference,
isActive: editData.isActive,
};
if (editData.password && editData.password.trim().length > 0) {
@@ -305,8 +310,8 @@ export default function AdminUsersPage() {
<th className="p-2">Email</th>
<th className="p-2">Role</th>
<th className="p-2">Phone</th>
<th className="p-2">Notify</th>
<th className="p-2">Active</th>
<th className="p-2">Password</th>
<th className="p-2">Actions</th>
</tr>
</thead>
@@ -314,63 +319,30 @@ export default function AdminUsersPage() {
{users.map(u => (
<tr key={u.id} className="border-t hover:bg-gray-50">
<td className="p-2">
{editingId === u.id ? (
<input className="border rounded px-2 py-1 w-44" value={editData.name || ""} onChange={e => setEditData(d => ({ ...d, name: e.target.value }))} />
) : (
<span className={`font-medium ${!u.isActive ? "text-gray-400" : ""}`}>{u.name}</span>
)}
<span className={`font-medium ${!u.isActive ? "text-gray-400" : ""}`}>{u.name}</span>
</td>
<td className="p-2">
{editingId === u.id ? (
<input className="border rounded px-2 py-1 w-60" value={editData.email || ""} onChange={e => setEditData(d => ({ ...d, email: e.target.value }))} />
) : (
<span className={u.email?.endsWith("@deleted.local") ? "text-gray-400 italic" : ""}>{u.email}</span>
)}
<span className={u.email?.endsWith("@deleted.local") ? "text-gray-400 italic" : ""}>{u.email}</span>
</td>
<td className="p-2">
{editingId === u.id ? (
<select className="border rounded px-2 py-1" value={(editData.role as Role) || (u.role as Role)} onChange={e => setEditData(d => ({ ...d, role: e.target.value }))}>
{roleOptions.map(r => <option key={r} value={r}>{r}</option>)}
</select>
) : (
<span className="capitalize">{u.role}</span>
)}
<span className="capitalize">{u.role}</span>
</td>
<td className="p-2">
{editingId === u.id ? (
<input className="border rounded px-2 py-1 w-36" value={editData.phoneNumber || ""} onChange={e => setEditData(d => ({ ...d, phoneNumber: e.target.value }))} />
) : (
<span>{u.phoneNumber || ""}</span>
)}
<span>{u.phoneNumber || ""}</span>
</td>
<td className="p-2">
{editingId === u.id ? (
<input type="checkbox" checked={!!editData.isActive} onChange={e => setEditData(d => ({ ...d, isActive: e.target.checked }))} />
) : (
<span className={u.isActive ? "text-green-700" : "text-gray-400"}>{u.isActive ? "Yes" : "No"}</span>
)}
<span className="capitalize">{u.notificationPreference || "email"}</span>
</td>
<td className="p-2">
{editingId === u.id ? (
<input type="password" placeholder="Set new password" className="border rounded px-2 py-1 w-44" value={editData.password || ""} onChange={e => setEditData(d => ({ ...d, password: e.target.value }))} />
) : (
<span className="text-gray-400"></span>
)}
<span className={u.isActive ? "text-green-700" : "text-gray-400"}>{u.isActive ? "Yes" : "No"}</span>
</td>
<td className="p-2">
{editingId === u.id ? (
<div className="flex gap-2">
<button className="px-2 py-1 text-xs rounded bg-gray-100 hover:bg-gray-200" onClick={cancelEdit} disabled={saving}>Cancel</button>
<button className="px-2 py-1 text-xs rounded bg-blue-600 text-white disabled:opacity-50" onClick={saveEdit} disabled={saving}>{saving ? "Saving…" : "Save"}</button>
</div>
) : (
<div className="flex gap-1 flex-wrap">
<button className="px-2 py-1 text-xs rounded bg-gray-100 hover:bg-gray-200" onClick={() => startEdit(u)}>Edit</button>
<button className="px-2 py-1 text-xs rounded bg-amber-500 text-white hover:bg-amber-600" onClick={() => revokeUserSessions(u.id, u.name)} title="Sign out all devices">Sessions</button>
<button className="px-2 py-1 text-xs rounded bg-orange-500 text-white hover:bg-orange-600" onClick={() => deactivate(u.id)} title="Deactivate account">Deactivate</button>
<button className="px-2 py-1 text-xs rounded bg-red-700 text-white hover:bg-red-800" onClick={() => deleteUserData(u.id, u.name)} title="Erase personal data">Delete data</button>
</div>
)}
<div className="flex gap-1 flex-wrap">
<button className="px-2 py-1 text-xs rounded bg-gray-100 hover:bg-gray-200" onClick={() => startEdit(u)}>Edit</button>
<button className="px-2 py-1 text-xs rounded bg-amber-500 text-white hover:bg-amber-600" onClick={() => revokeUserSessions(u.id, u.name)} title="Force this user to sign in again on every device where they're currently logged in">Sign out everywhere</button>
<button className="px-2 py-1 text-xs rounded bg-orange-500 text-white hover:bg-orange-600" onClick={() => deactivate(u.id)} title="Deactivate account">Deactivate</button>
<button className="px-2 py-1 text-xs rounded bg-red-700 text-white hover:bg-red-800" onClick={() => deleteUserData(u.id, u.name)} title="Erase personal data">Delete data</button>
</div>
</td>
</tr>
))}
@@ -431,6 +403,70 @@ export default function AdminUsersPage() {
</div>
)}
</div>
{editingUser && (
<div className="fixed inset-0 z-20">
<div className="absolute inset-0 bg-black/30" onClick={() => !saving && cancelEdit()} />
<div className="absolute inset-0 flex items-center justify-center p-4">
<div className="w-full max-w-lg bg-white rounded-lg shadow-lg border p-4">
<div className="flex items-center justify-between mb-3">
<h2 className="text-base font-semibold">Edit user</h2>
<button type="button" className="text-xs px-2 py-1 rounded bg-gray-100 hover:bg-gray-200" onClick={cancelEdit} disabled={saving}>Close</button>
</div>
<div className="grid gap-3">
<div>
<label className="block text-xs text-gray-600 mb-1">Name</label>
<input className="w-full border rounded px-3 py-2 text-sm" value={editData.name || ""} onChange={e => setEditData(d => ({ ...d, name: e.target.value }))} />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Email</label>
<input className="w-full border rounded px-3 py-2 text-sm" value={editData.email || ""} onChange={e => setEditData(d => ({ ...d, email: e.target.value }))} />
</div>
<div className="grid sm:grid-cols-2 gap-3">
<div>
<label className="block text-xs text-gray-600 mb-1">Role</label>
<select className="w-full border rounded px-3 py-2 text-sm" value={(editData.role as Role) || "user"} onChange={e => setEditData(d => ({ ...d, role: e.target.value }))}>
{roleOptions.map(r => <option key={r} value={r}>{r}</option>)}
</select>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Phone</label>
<input className="w-full border rounded px-3 py-2 text-sm" value={editData.phoneNumber || ""} onChange={e => setEditData(d => ({ ...d, phoneNumber: e.target.value }))} />
</div>
</div>
<div className="grid sm:grid-cols-2 gap-3">
<div>
<label className="block text-xs text-gray-600 mb-1">Notification preference</label>
<select
className="w-full border rounded px-3 py-2 text-sm"
value={editData.notificationPreference || "email"}
onChange={e => setEditData(d => ({ ...d, notificationPreference: e.target.value }))}
>
{notificationPreferenceOptions.map(p => (
<option key={p} value={p} disabled={p !== "email" && !editData.phoneNumber}>{p}</option>
))}
</select>
</div>
<div className="flex items-end pb-2">
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={!!editData.isActive} onChange={e => setEditData(d => ({ ...d, isActive: e.target.checked }))} />
Active
</label>
</div>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">New password (leave blank to keep current)</label>
<input type="password" placeholder="Set new password" className="w-full border rounded px-3 py-2 text-sm" value={editData.password || ""} onChange={e => setEditData(d => ({ ...d, password: e.target.value }))} />
</div>
</div>
<div className="flex justify-end gap-2 mt-4">
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200" onClick={cancelEdit} disabled={saving}>Cancel</button>
<button className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white disabled:opacity-50" onClick={saveEdit} disabled={saving}>{saving ? "Saving…" : "Save"}</button>
</div>
</div>
</div>
</div>
)}
</div>
);
}