Fix login hang after PIN + enrich Banquet mode
Some checks failed
Build Android APK / build-apk (push) Failing after 57m31s
Some checks failed
Build Android APK / build-apk (push) Failing after 57m31s
- Auth: introduce a shared AuthProvider/useAuth so entering the PIN updates reactive state and the layout's gate navigates immediately (previously it cached the token check at startup, so login appeared to hang until a manual refresh). login/scanner now use signIn/signOut. - Banquet: donor lookup now returns lifetime giving, last-12-months giving (computed from dated transactions), member/donor status, bear name, and tags. The banquet result screen shows status badge + lifetime and last-year figures. Tickets are irrelevant in banquet mode. - Admin: fix "‹ Scanner" back link wrapping (remove fixed width). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
dc39c41428
commit
1ca2c38fbf
7 changed files with 268 additions and 90 deletions
|
|
@ -4,17 +4,22 @@ export interface DonorLookup {
|
|||
found: boolean;
|
||||
email: string;
|
||||
name: string;
|
||||
online: number;
|
||||
offline: number;
|
||||
total: number;
|
||||
bearName: string;
|
||||
lifetime: number; // total all-time giving
|
||||
lastYear: number; // giving in the last 12 months
|
||||
online: number; // lifetime online
|
||||
offline: number; // lifetime offline
|
||||
isMember: boolean; // appears to be a current member
|
||||
tags: string[];
|
||||
status: string; // short human status line
|
||||
source: "master" | "transactions" | "none";
|
||||
}
|
||||
|
||||
/**
|
||||
* Looks up a donor's total giving for Banquet mode. Primary source is the
|
||||
* "Donors Master List" (which carries pre-rolled Total Donations / Total
|
||||
* Online / Total Offline). Falls back to summing the online + offline
|
||||
* transaction tables by email when the donor isn't in the master list.
|
||||
* Looks up a donor for Banquet mode. Lifetime totals come from the "Donors
|
||||
* Master List" (authoritative pre-rolled totals); the last-12-months figure is
|
||||
* computed from the online + offline transaction tables (the only source with
|
||||
* dates). Tickets are irrelevant here — banquet only cares about giving.
|
||||
*/
|
||||
export class DonorService {
|
||||
private readonly base: string;
|
||||
|
|
@ -59,44 +64,97 @@ export class DonorService {
|
|||
async lookup(rawEmail: string): Promise<DonorLookup> {
|
||||
const email = rawEmail.trim();
|
||||
const esc = email.replace(/[(),]/g, " ");
|
||||
const empty: DonorLookup = { found: false, email, name: "", online: 0, offline: 0, total: 0, source: "none" };
|
||||
const empty: DonorLookup = {
|
||||
found: false,
|
||||
email,
|
||||
name: "",
|
||||
bearName: "",
|
||||
lifetime: 0,
|
||||
lastYear: 0,
|
||||
online: 0,
|
||||
offline: 0,
|
||||
isMember: false,
|
||||
tags: [],
|
||||
status: "",
|
||||
source: "none",
|
||||
};
|
||||
if (!email) return empty;
|
||||
|
||||
// 1) Master list (authoritative rolled-up totals), matching either email.
|
||||
if (this.masterId) {
|
||||
const rows = await this.list(
|
||||
this.masterId,
|
||||
`(Email,eq,${esc})~or(Alternate Email,eq,${esc})`,
|
||||
1,
|
||||
);
|
||||
if (rows.length) {
|
||||
const r = rows[0];
|
||||
const online = num(r["Total Online Donations"]);
|
||||
const offline = num(r["Total Offline Donations"]);
|
||||
const total = r["Total Donations"] !== undefined ? num(r["Total Donations"]) : online + offline;
|
||||
const name =
|
||||
r["Display Name"] ||
|
||||
[r["First Name"], r["Last Name"]].filter(Boolean).join(" ") ||
|
||||
r["Bear Name"] ||
|
||||
"";
|
||||
return { found: true, email, name, online, offline, total, source: "master" };
|
||||
}
|
||||
// Always pull transactions (needed for the last-12-months figure and as a
|
||||
// lifetime fallback). Runs in parallel with the master-list lookup.
|
||||
const txnP: Promise<{ online: any[]; offline: any[] }> =
|
||||
this.onlineId && this.offlineId
|
||||
? Promise.all([
|
||||
this.list(this.onlineId, `(Email,eq,${esc})`, 500),
|
||||
this.list(this.offlineId, `(Email,eq,${esc})`, 500),
|
||||
]).then(([online, offline]) => ({ online, offline }))
|
||||
: Promise.resolve({ online: [], offline: [] });
|
||||
|
||||
const masterP: Promise<any | null> = this.masterId
|
||||
? this.list(this.masterId, `(Email,eq,${esc})~or(Alternate Email,eq,${esc})`, 1).then((r) => r[0] ?? null)
|
||||
: Promise.resolve(null);
|
||||
|
||||
const [{ online: onlineRows, offline: offlineRows }, master] = await Promise.all([txnP, masterP]);
|
||||
|
||||
// Last 12 months, summed from dated transactions (Paid or unspecified).
|
||||
const cutoff = new Date();
|
||||
cutoff.setFullYear(cutoff.getFullYear() - 1);
|
||||
const lastYear =
|
||||
sumSince(onlineRows, "Donation Amount", "Donation Date", cutoff) +
|
||||
sumSince(offlineRows, "Donation Amount", "Donation Date", cutoff);
|
||||
|
||||
const txnOnline = sumPaid(onlineRows, "Donation Amount");
|
||||
const txnOffline = sumPaid(offlineRows, "Donation Amount");
|
||||
|
||||
if (master) {
|
||||
const online = master["Total Online Donations"] !== undefined ? num(master["Total Online Donations"]) : txnOnline;
|
||||
const offline =
|
||||
master["Total Offline Donations"] !== undefined ? num(master["Total Offline Donations"]) : txnOffline;
|
||||
const lifetime =
|
||||
master["Total Donations"] !== undefined ? num(master["Total Donations"]) : online + offline;
|
||||
const name =
|
||||
master["Display Name"] ||
|
||||
[master["First Name"], master["Last Name"]].filter(Boolean).join(" ") ||
|
||||
master["Bear Name"] ||
|
||||
"";
|
||||
const tags = splitTags(master["Tags"]);
|
||||
const upcoming = String(master["Upcoming Rewards"] ?? "");
|
||||
const isMember = /member/i.test(upcoming) || tags.some((t) => /member/i.test(t));
|
||||
return {
|
||||
found: true,
|
||||
email,
|
||||
name,
|
||||
bearName: String(master["Bear Name"] ?? ""),
|
||||
lifetime,
|
||||
lastYear,
|
||||
online,
|
||||
offline,
|
||||
isMember,
|
||||
tags,
|
||||
status: isMember ? "Member" : "Donor",
|
||||
source: "master",
|
||||
};
|
||||
}
|
||||
|
||||
// 2) Fallback: sum transaction tables by email.
|
||||
if (this.onlineId && this.offlineId) {
|
||||
const [onlineRows, offlineRows] = await Promise.all([
|
||||
this.list(this.onlineId, `(Email,eq,${esc})`, 200),
|
||||
this.list(this.offlineId, `(Email,eq,${esc})`, 200),
|
||||
]);
|
||||
const online = sum(onlineRows, "Donation Amount");
|
||||
const offline = sum(offlineRows, "Donation Amount");
|
||||
const name = onlineRows[0]?.["Bear Name"] || onlineRows[0]?.["Name"] || offlineRows[0]?.["Name"] || "";
|
||||
const found = online + offline > 0 || onlineRows.length + offlineRows.length > 0;
|
||||
return { found, email, name, online, offline, total: online + offline, source: found ? "transactions" : "none" };
|
||||
}
|
||||
|
||||
return empty;
|
||||
// Not in the master list: build from transactions alone.
|
||||
const found = txnOnline + txnOffline > 0 || onlineRows.length + offlineRows.length > 0;
|
||||
if (!found) return empty;
|
||||
const name = onlineRows[0]?.["Name"] || offlineRows[0]?.["Name"] || "";
|
||||
const bearName = onlineRows[0]?.["Bear Name"] || offlineRows[0]?.["Bear Name"] || "";
|
||||
return {
|
||||
found: true,
|
||||
email,
|
||||
name,
|
||||
bearName,
|
||||
lifetime: txnOnline + txnOffline,
|
||||
lastYear,
|
||||
online: txnOnline,
|
||||
offline: txnOffline,
|
||||
isMember: false,
|
||||
tags: [],
|
||||
status: "Donor",
|
||||
source: "transactions",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -105,6 +163,30 @@ function num(v: unknown): number {
|
|||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
function sum(rows: any[], field: string): number {
|
||||
return rows.reduce((acc, r) => acc + num(r[field]), 0);
|
||||
// Count a transaction unless it's explicitly not paid (refunded/failed/pending).
|
||||
function isPaid(row: any): boolean {
|
||||
const s = String(row["Payment Status"] ?? "").trim();
|
||||
if (!s) return true;
|
||||
return /paid|complete|success/i.test(s);
|
||||
}
|
||||
|
||||
function sumPaid(rows: any[], amountField: string): number {
|
||||
return rows.reduce((acc, r) => (isPaid(r) ? acc + num(r[amountField]) : acc), 0);
|
||||
}
|
||||
|
||||
function sumSince(rows: any[], amountField: string, dateField: string, cutoff: Date): number {
|
||||
return rows.reduce((acc, r) => {
|
||||
if (!isPaid(r)) return acc;
|
||||
const raw = r[dateField];
|
||||
if (!raw) return acc;
|
||||
const d = new Date(raw);
|
||||
if (isNaN(d.getTime()) || d < cutoff) return acc;
|
||||
return acc + num(r[amountField]);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function splitTags(v: unknown): string[] {
|
||||
if (Array.isArray(v)) return v.map((x) => String(x)).filter(Boolean);
|
||||
if (typeof v === "string") return v.split(",").map((s) => s.trim()).filter(Boolean);
|
||||
return [];
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue