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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user