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
|
|
@ -1,30 +1,33 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { View, ActivityIndicator } from "react-native";
|
||||
import { Stack, useRouter, useSegments } from "expo-router";
|
||||
import { SafeAreaProvider } from "react-native-safe-area-context";
|
||||
import { StatusBar } from "expo-status-bar";
|
||||
import { getToken } from "../lib/api";
|
||||
import { AuthProvider, useAuth } from "../lib/auth";
|
||||
import { theme } from "../lib/theme";
|
||||
|
||||
export default function RootLayout() {
|
||||
const [ready, setReady] = useState(false);
|
||||
const [hasToken, setHasToken] = useState(false);
|
||||
return (
|
||||
<SafeAreaProvider>
|
||||
<StatusBar style="light" />
|
||||
<AuthProvider>
|
||||
<AuthGate />
|
||||
</AuthProvider>
|
||||
</SafeAreaProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthGate() {
|
||||
const { ready, signedIn } = useAuth();
|
||||
const router = useRouter();
|
||||
const segments = useSegments();
|
||||
|
||||
useEffect(() => {
|
||||
getToken().then((t) => {
|
||||
setHasToken(!!t);
|
||||
setReady(true);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready) return;
|
||||
const onLogin = segments[0] === "login";
|
||||
if (!hasToken && !onLogin) router.replace("/login");
|
||||
if (hasToken && onLogin) router.replace("/");
|
||||
}, [ready, hasToken, segments, router]);
|
||||
if (!signedIn && !onLogin) router.replace("/login");
|
||||
if (signedIn && onLogin) router.replace("/");
|
||||
}, [ready, signedIn, segments, router]);
|
||||
|
||||
if (!ready) {
|
||||
return (
|
||||
|
|
@ -35,15 +38,12 @@ export default function RootLayout() {
|
|||
}
|
||||
|
||||
return (
|
||||
<SafeAreaProvider>
|
||||
<StatusBar style="light" />
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
contentStyle: { backgroundColor: theme.bg },
|
||||
animation: "fade",
|
||||
}}
|
||||
/>
|
||||
</SafeAreaProvider>
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
contentStyle: { backgroundColor: theme.bg },
|
||||
animation: "fade",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -119,10 +119,12 @@ export default function AdminScreen() {
|
|||
<SafeAreaView style={styles.root} edges={["top", "bottom"]}>
|
||||
<View style={styles.topbar}>
|
||||
<Pressable onPress={() => router.replace("/")} hitSlop={10}>
|
||||
<Text style={styles.link}>‹ Scanner</Text>
|
||||
<Text style={styles.link} numberOfLines={1}>
|
||||
‹ Scanner
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Text style={styles.brand}>Admin lookup</Text>
|
||||
<View style={{ width: 60 }} />
|
||||
<View style={{ width: 72 }} />
|
||||
</View>
|
||||
|
||||
<View style={styles.searchRow}>
|
||||
|
|
@ -269,7 +271,7 @@ const styles = StyleSheet.create({
|
|||
paddingVertical: 10,
|
||||
},
|
||||
brand: { color: theme.text, fontSize: 18, fontWeight: "700" },
|
||||
link: { color: theme.textDim, fontSize: 16, fontWeight: "600", width: 60 },
|
||||
link: { color: theme.textDim, fontSize: 16, fontWeight: "600" },
|
||||
searchRow: { flexDirection: "row", gap: 10, paddingHorizontal: 16, marginTop: 6 },
|
||||
input: {
|
||||
flex: 1,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ import { router } from "expo-router";
|
|||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import QRScanner from "../components/QRScanner";
|
||||
import ResultOverlay from "../components/ResultOverlay";
|
||||
import { lookup, redeem, banquet, logout, type TicketView, type DonorLookup } from "../lib/api";
|
||||
import { lookup, redeem, banquet, type TicketView, type DonorLookup } from "../lib/api";
|
||||
import { useAuth } from "../lib/auth";
|
||||
import { feedbackSuccess, feedbackError } from "../lib/feedback";
|
||||
import { theme } from "../lib/theme";
|
||||
|
||||
|
|
@ -18,6 +19,7 @@ const MODES: { key: Mode; label: string; icon: string }[] = [
|
|||
];
|
||||
|
||||
export default function ScannerScreen() {
|
||||
const { signOut } = useAuth();
|
||||
const [mode, setMode] = useState<Mode>("tickets");
|
||||
const [phase, setPhase] = useState<Phase>("scanning");
|
||||
const [ticket, setTicket] = useState<TicketView | null>(null);
|
||||
|
|
@ -143,9 +145,9 @@ export default function ScannerScreen() {
|
|||
}, [ticket, count, mode, resume, showError]);
|
||||
|
||||
const doLogout = useCallback(async () => {
|
||||
await logout();
|
||||
router.replace("/login");
|
||||
}, []);
|
||||
await signOut();
|
||||
// The auth gate redirects to /login when signedIn flips to false.
|
||||
}, [signOut]);
|
||||
|
||||
const isIce = mode === "ice";
|
||||
const successNoun = isIce ? (checkedIn === 1 ? "bag of ice" : "bags of ice") : "";
|
||||
|
|
@ -293,7 +295,7 @@ function BanquetResult({ donor, ticketName }: { donor: DonorLookup | null; ticke
|
|||
return (
|
||||
<>
|
||||
<Text style={styles.bigIcon}>—</Text>
|
||||
<Text style={styles.bigTitle}>No donations found</Text>
|
||||
<Text style={styles.bigTitle}>No donor record</Text>
|
||||
<Text style={styles.name}>{donor.email}</Text>
|
||||
{!!ticketName && <Text style={styles.counts}>Ticket: {ticketName}</Text>}
|
||||
</>
|
||||
|
|
@ -301,13 +303,33 @@ function BanquetResult({ donor, ticketName }: { donor: DonorLookup | null; ticke
|
|||
}
|
||||
return (
|
||||
<>
|
||||
<Text style={styles.bigTitle}>{donor.name || ticketName || donor.email}</Text>
|
||||
<Text style={styles.donorTotal}>{money(donor.total)}</Text>
|
||||
<Text style={styles.counts}>total donated</Text>
|
||||
<View style={styles.donorBreak}>
|
||||
<Text style={styles.donorBreakItem}>Online {money(donor.online)}</Text>
|
||||
<Text style={styles.donorBreakItem}>Offline {money(donor.offline)}</Text>
|
||||
<Text style={styles.bigTitle}>{donor.name || donor.bearName || ticketName || donor.email}</Text>
|
||||
{!!donor.bearName && donor.bearName !== donor.name && (
|
||||
<Text style={styles.donorBear}>{donor.bearName}</Text>
|
||||
)}
|
||||
<View style={styles.statusRow}>
|
||||
<Text style={[styles.statusBadge, donor.isMember && styles.statusMember]}>
|
||||
{donor.isMember ? "⭐ Member" : donor.status || "Donor"}
|
||||
</Text>
|
||||
{donor.tags.map((t) => (
|
||||
<Text key={t} style={styles.statusBadge}>
|
||||
{t}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View style={styles.donorFigures}>
|
||||
<View style={styles.donorFigure}>
|
||||
<Text style={styles.donorFigureAmt}>{money(donor.lifetime)}</Text>
|
||||
<Text style={styles.donorFigureLbl}>lifetime</Text>
|
||||
</View>
|
||||
<View style={styles.donorFigureDivider} />
|
||||
<View style={styles.donorFigure}>
|
||||
<Text style={styles.donorFigureAmt}>{money(donor.lastYear)}</Text>
|
||||
<Text style={styles.donorFigureLbl}>last 12 months</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text style={styles.donorEmail}>{donor.email}</Text>
|
||||
</>
|
||||
);
|
||||
|
|
@ -438,10 +460,25 @@ const styles = StyleSheet.create({
|
|||
errorMsg: { color: "#fff", fontSize: 18, marginTop: 12, textAlign: "center", lineHeight: 24 },
|
||||
tapHint: { color: "rgba(255,255,255,0.75)", fontSize: 14, marginTop: 24 },
|
||||
|
||||
donorTotal: { color: "#fff", fontSize: 64, fontWeight: "900", marginTop: 10 },
|
||||
donorBreak: { flexDirection: "row", gap: 18, marginTop: 14 },
|
||||
donorBreakItem: { color: "rgba(255,255,255,0.95)", fontSize: 16, fontWeight: "600" },
|
||||
donorEmail: { color: "rgba(255,255,255,0.85)", fontSize: 14, marginTop: 14 },
|
||||
donorBear: { color: "rgba(255,255,255,0.9)", fontSize: 17, marginTop: 4, fontStyle: "italic" },
|
||||
statusRow: { flexDirection: "row", flexWrap: "wrap", justifyContent: "center", gap: 8, marginTop: 14 },
|
||||
statusBadge: {
|
||||
color: "#fff",
|
||||
backgroundColor: "rgba(255,255,255,0.18)",
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 999,
|
||||
fontSize: 14,
|
||||
fontWeight: "700",
|
||||
overflow: "hidden",
|
||||
},
|
||||
statusMember: { backgroundColor: "rgba(255,215,0,0.28)" },
|
||||
donorFigures: { flexDirection: "row", alignItems: "center", marginTop: 22 },
|
||||
donorFigure: { alignItems: "center", paddingHorizontal: 18 },
|
||||
donorFigureAmt: { color: "#fff", fontSize: 40, fontWeight: "900" },
|
||||
donorFigureLbl: { color: "rgba(255,255,255,0.85)", fontSize: 14, marginTop: 4 },
|
||||
donorFigureDivider: { width: 1, alignSelf: "stretch", backgroundColor: "rgba(255,255,255,0.35)", marginVertical: 8 },
|
||||
donorEmail: { color: "rgba(255,255,255,0.85)", fontSize: 14, marginTop: 18 },
|
||||
|
||||
tags: { flexDirection: "row", flexWrap: "wrap", justifyContent: "center", gap: 8, marginTop: 14 },
|
||||
tag: { color: "#fff", backgroundColor: "rgba(255,255,255,0.18)", paddingHorizontal: 10, paddingVertical: 5, borderRadius: 999, fontSize: 13, overflow: "hidden" },
|
||||
|
|
|
|||
|
|
@ -1,14 +1,15 @@
|
|||
import { useState } from "react";
|
||||
import { StyleSheet, View, Text, Pressable } from "react-native";
|
||||
import { router } from "expo-router";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { login, AuthError } from "../lib/api";
|
||||
import { AuthError } from "../lib/api";
|
||||
import { useAuth } from "../lib/auth";
|
||||
import { primeFeedback } from "../lib/feedback";
|
||||
import { theme } from "../lib/theme";
|
||||
|
||||
const KEYS = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "clear", "0", "back"];
|
||||
|
||||
export default function LoginScreen() {
|
||||
const { signIn } = useAuth();
|
||||
const [pin, setPin] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
|
@ -28,8 +29,8 @@ export default function LoginScreen() {
|
|||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await login(pin);
|
||||
router.replace("/");
|
||||
await signIn(pin);
|
||||
// The auth gate in _layout navigates once signedIn flips to true.
|
||||
} catch (e: any) {
|
||||
setError(e instanceof AuthError ? "Incorrect PIN" : (e?.message ?? "Login failed"));
|
||||
setPin("");
|
||||
|
|
|
|||
|
|
@ -59,9 +59,17 @@ export async function login(pin: string): Promise<void> {
|
|||
await saveToken(token);
|
||||
}
|
||||
|
||||
// Lets the auth provider react when the token is cleared (e.g. on a 401), so
|
||||
// UI state stays in sync with storage.
|
||||
let onCleared: (() => void) | null = null;
|
||||
export function onAuthCleared(cb: (() => void) | null): void {
|
||||
onCleared = cb;
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
cachedToken = null;
|
||||
await clearToken();
|
||||
onCleared?.();
|
||||
}
|
||||
|
||||
async function authed<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
|
|
@ -128,9 +136,14 @@ export interface DonorLookup {
|
|||
found: boolean;
|
||||
email: string;
|
||||
name: string;
|
||||
bearName: string;
|
||||
lifetime: number;
|
||||
lastYear: number;
|
||||
online: number;
|
||||
offline: number;
|
||||
total: number;
|
||||
isMember: boolean;
|
||||
tags: string[];
|
||||
status: string;
|
||||
source: "master" | "transactions" | "none";
|
||||
}
|
||||
|
||||
|
|
|
|||
43
app/lib/auth.tsx
Normal file
43
app/lib/auth.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
|
||||
import { login as apiLogin, logout as apiLogout, getToken, onAuthCleared } from "./api";
|
||||
|
||||
interface AuthState {
|
||||
ready: boolean; // finished the initial token load
|
||||
signedIn: boolean;
|
||||
signIn: (pin: string) => Promise<void>;
|
||||
signOut: () => Promise<void>;
|
||||
}
|
||||
|
||||
const Ctx = createContext<AuthState | null>(null);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [ready, setReady] = useState(false);
|
||||
const [signedIn, setSignedIn] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
getToken().then((t) => {
|
||||
setSignedIn(!!t);
|
||||
setReady(true);
|
||||
});
|
||||
// Keep state in sync when the token is cleared elsewhere (401 handling).
|
||||
onAuthCleared(() => setSignedIn(false));
|
||||
return () => onAuthCleared(null);
|
||||
}, []);
|
||||
|
||||
const signIn = async (pin: string) => {
|
||||
await apiLogin(pin);
|
||||
setSignedIn(true);
|
||||
};
|
||||
const signOut = async () => {
|
||||
await apiLogout();
|
||||
setSignedIn(false);
|
||||
};
|
||||
|
||||
return <Ctx.Provider value={{ ready, signedIn, signIn, signOut }}>{children}</Ctx.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth(): AuthState {
|
||||
const c = useContext(Ctx);
|
||||
if (!c) throw new Error("useAuth must be used within AuthProvider");
|
||||
return c;
|
||||
}
|
||||
|
|
@ -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