From b771858280f35572a75a73788b1204f11b7f54d8 Mon Sep 17 00:00:00 2001 From: Omair Saleh Date: Wed, 4 Mar 2026 21:48:10 +0800 Subject: [PATCH] Settings + Admin redesign + Community Leader role MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## New: Community Leader role Who: Imam Yusuf, Sister Mariam, Uncle Tariq — the person who rallies their mosque, WhatsApp group, neighbourhood to pledge. Not an admin. Not a volunteer. A logged-in coordinator who needs more than a live feed but less than full admin access. /dashboard/community — their scoped dashboard: - 'How are WE doing?' — their stats vs the whole appeal (dark hero section) - Contribution percentage bar - Their links with full share buttons (Copy/WhatsApp/Email/QR) - Create new links (auto-tagged with their name) - Leaderboard: 'How communities compare' with 'You' badge - Read-only pledge list (no status changes, no bank details) Navigation changes for community_leader role: - Sees: My Community → Share Links → Reports (3 items) - Does NOT see: Home, Money, Settings, New Appeal button - Does NOT see: Bank details, WhatsApp config, reconciliation ## New: Team management API + UI GET/POST/PATCH/DELETE /api/team — CRUD for team members - Only org_admin/super_admin can invite - Temp password generated on invite (shown once) - Copy credentials or send via WhatsApp button - Role selector with descriptions (Admin, Community Leader, Staff, Volunteer) - Role change via dropdown, remove with trash icon - Can't change own role or remove self ## Settings page redesign Reordered by Aaisha's thinking: 1. WhatsApp (unchanged — most important) 2. Team (NEW — 'who has access? invite community leaders') 3. Bank account 4. Charity details 5. Direct Debit (collapsed in
) Team section shows: - All members with role icons (Crown/Users/Eye) - Inline role change dropdown - Remove button - Invite form with role cards and descriptions - Credentials shown once with copy + WhatsApp share buttons ## Admin page redesign Brand-consistent: no more shadcn Card/Badge/Table - Dark hero section with 7 platform stats - Pipeline status breakdown (gap-px grid) - Pill tab switcher (not shadcn Tabs) - Grid tables matching the rest of the dashboard - Role badges color-coded (blue super, green admin, amber leader) 6 files changed, 4 new routes/pages --- .../src/app/api/team/route.ts | 152 ++++++ .../src/app/dashboard/admin/page.tsx | 342 ++++++------ .../src/app/dashboard/community/page.tsx | 350 +++++++++++++ .../src/app/dashboard/layout.tsx | 44 +- .../src/app/dashboard/settings/page.tsx | 338 +++++++++--- temp_files/v3/AppealResource.php | 429 +++++++++++++++ temp_files/v3/DonationResource.php | 493 ++++++++++++++++++ temp_files/v3/FundraiserNurtureWidget.php | 127 +++++ temp_files/v3/ListAppeals.php | 40 ++ temp_files/v3/ListDonations.php | 40 ++ 10 files changed, 2113 insertions(+), 242 deletions(-) create mode 100644 pledge-now-pay-later/src/app/api/team/route.ts create mode 100644 pledge-now-pay-later/src/app/dashboard/community/page.tsx create mode 100644 temp_files/v3/AppealResource.php create mode 100644 temp_files/v3/DonationResource.php create mode 100644 temp_files/v3/FundraiserNurtureWidget.php create mode 100644 temp_files/v3/ListAppeals.php create mode 100644 temp_files/v3/ListDonations.php diff --git a/pledge-now-pay-later/src/app/api/team/route.ts b/pledge-now-pay-later/src/app/api/team/route.ts new file mode 100644 index 0000000..d6353fc --- /dev/null +++ b/pledge-now-pay-later/src/app/api/team/route.ts @@ -0,0 +1,152 @@ +import { NextRequest, NextResponse } from "next/server" +import prisma from "@/lib/prisma" +import { getUser, getOrgId } from "@/lib/session" +import { hash } from "bcryptjs" +import { customAlphabet } from "nanoid" + +const generateTempPassword = customAlphabet("23456789abcdefghjkmnpqrstuvwxyz", 12) + +/** + * GET /api/team — List team members for the current org + */ +export async function GET() { + try { + if (!prisma) return NextResponse.json({ members: [] }) + const orgId = await getOrgId(null) + if (!orgId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + + const members = await prisma.user.findMany({ + where: { organizationId: orgId }, + select: { id: true, email: true, name: true, role: true, createdAt: true }, + orderBy: { createdAt: "asc" }, + }) + + return NextResponse.json({ members }) + } catch (error) { + console.error("Team GET error:", error) + return NextResponse.json({ members: [] }) + } +} + +/** + * POST /api/team — Invite a new team member + * Only org_admin can invite. Creates a user with a temp password. + */ +export async function POST(request: NextRequest) { + try { + if (!prisma) return NextResponse.json({ error: "DB not configured" }, { status: 503 }) + + const user = await getUser() + if (!user || (user.role !== "org_admin" && user.role !== "super_admin")) { + return NextResponse.json({ error: "Only admins can invite team members" }, { status: 403 }) + } + + const { email, name, role } = await request.json() + if (!email) return NextResponse.json({ error: "Email is required" }, { status: 400 }) + + const cleanEmail = email.toLowerCase().trim() + const validRoles = ["org_admin", "community_leader", "staff", "volunteer"] + const memberRole = validRoles.includes(role) ? role : "staff" + + // Check if already exists + const existing = await prisma.user.findUnique({ where: { email: cleanEmail } }) + if (existing) { + return NextResponse.json({ error: "A user with this email already exists" }, { status: 409 }) + } + + // Create with temp password + const tempPassword = generateTempPassword() + const hashedPassword = await hash(tempPassword, 12) + + const newUser = await prisma.user.create({ + data: { + email: cleanEmail, + name: name?.trim() || null, + hashedPassword, + role: memberRole, + organizationId: user.orgId, + }, + }) + + return NextResponse.json({ + id: newUser.id, + email: newUser.email, + name: newUser.name, + role: newUser.role, + tempPassword, // Show once so admin can share it + }, { status: 201 }) + } catch (error) { + console.error("Team POST error:", error) + return NextResponse.json({ error: "Failed to invite" }, { status: 500 }) + } +} + +/** + * PATCH /api/team — Update a team member's role + */ +export async function PATCH(request: NextRequest) { + try { + if (!prisma) return NextResponse.json({ error: "DB not configured" }, { status: 503 }) + + const user = await getUser() + if (!user || (user.role !== "org_admin" && user.role !== "super_admin")) { + return NextResponse.json({ error: "Only admins can change roles" }, { status: 403 }) + } + + const { userId, role } = await request.json() + if (!userId || !role) return NextResponse.json({ error: "userId and role required" }, { status: 400 }) + + const validRoles = ["org_admin", "community_leader", "staff", "volunteer"] + if (!validRoles.includes(role)) { + return NextResponse.json({ error: "Invalid role" }, { status: 400 }) + } + + // Don't let admin demote themselves + if (userId === user.id) { + return NextResponse.json({ error: "You can't change your own role" }, { status: 400 }) + } + + // Ensure target user is in the same org + const target = await prisma.user.findFirst({ + where: { id: userId, organizationId: user.orgId }, + }) + if (!target) return NextResponse.json({ error: "User not found" }, { status: 404 }) + + await prisma.user.update({ where: { id: userId }, data: { role } }) + + return NextResponse.json({ success: true }) + } catch (error) { + console.error("Team PATCH error:", error) + return NextResponse.json({ error: "Failed to update" }, { status: 500 }) + } +} + +/** + * DELETE /api/team — Remove a team member + */ +export async function DELETE(request: NextRequest) { + try { + if (!prisma) return NextResponse.json({ error: "DB not configured" }, { status: 503 }) + + const user = await getUser() + if (!user || (user.role !== "org_admin" && user.role !== "super_admin")) { + return NextResponse.json({ error: "Only admins can remove members" }, { status: 403 }) + } + + const { userId } = await request.json() + if (!userId) return NextResponse.json({ error: "userId required" }, { status: 400 }) + if (userId === user.id) return NextResponse.json({ error: "You can't remove yourself" }, { status: 400 }) + + const target = await prisma.user.findFirst({ + where: { id: userId, organizationId: user.orgId }, + }) + if (!target) return NextResponse.json({ error: "User not found" }, { status: 404 }) + + await prisma.user.delete({ where: { id: userId } }) + + return NextResponse.json({ success: true }) + } catch (error) { + console.error("Team DELETE error:", error) + return NextResponse.json({ error: "Failed to remove" }, { status: 500 }) + } +} diff --git a/pledge-now-pay-later/src/app/dashboard/admin/page.tsx b/pledge-now-pay-later/src/app/dashboard/admin/page.tsx index ac1cd5d..1216f8d 100644 --- a/pledge-now-pay-later/src/app/dashboard/admin/page.tsx +++ b/pledge-now-pay-later/src/app/dashboard/admin/page.tsx @@ -2,23 +2,42 @@ import { useState, useEffect } from "react" import { useSession } from "next-auth/react" -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" -import { Badge } from "@/components/ui/badge" -import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table" -import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs" -import { - Shield, Building2, Users, Banknote, Calendar, TrendingUp, Loader2, AlertTriangle -} from "lucide-react" +import { formatPence } from "@/lib/utils" +import { Shield, Loader2, AlertTriangle, Building2, Users, Banknote } from "lucide-react" + +/** + * /dashboard/admin — Platform-wide super admin view + * + * Brand-consistent redesign. No shadcn Card/Badge/Table. + * Sharp edges, gap-px grids, left-border accents. + * + * This is Omair's view — the platform owner. + * He thinks: "How many orgs are using this? Who's stuck? Where's the money?" + */ interface AdminData { platform: { orgs: number; users: number; events: number; pledges: number; totalPledgedPence: number; totalCollectedPence: number; collectionRate: number } orgs: Array<{ id: string; name: string; slug: string; hasBank: boolean; users: number; events: number; pledges: number; createdAt: string }> users: Array<{ id: string; email: string; name: string | null; role: string; orgName: string; createdAt: string }> byStatus: Record - recentPledges: Array<{ id: string; reference: string; amountPence: number; status: string; donorName: string | null; eventName: string; orgName: string; dueDate: string | null; createdAt: string }> + recentPledges: Array<{ id: string; reference: string; amountPence: number; status: string; donorName: string | null; eventName: string; orgName: string; createdAt: string }> } -const fmt = (p: number) => `£${(p / 100).toLocaleString("en-GB", { minimumFractionDigits: 0 })}` +const STATUS: Record = { + new: { label: "Waiting", color: "text-gray-600", bg: "bg-gray-100" }, + initiated: { label: "Said paid", color: "text-[#F59E0B]", bg: "bg-[#F59E0B]/10" }, + paid: { label: "Received", color: "text-[#16A34A]", bg: "bg-[#16A34A]/10" }, + overdue: { label: "Overdue", color: "text-[#DC2626]", bg: "bg-[#DC2626]/10" }, + cancelled: { label: "Cancelled", color: "text-gray-400", bg: "bg-gray-50" }, +} + +const ROLE_LABELS: Record = { + super_admin: "Super Admin", + org_admin: "Admin", + community_leader: "Community Leader", + staff: "Staff", + volunteer: "Volunteer", +} export default function AdminPage() { const { data: session } = useSession() @@ -27,7 +46,7 @@ export default function AdminPage() { const [data, setData] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState("") - const [tab, setTab] = useState("orgs") + const [tab, setTab] = useState<"orgs" | "users" | "pledges">("orgs") useEffect(() => { fetch("/api/admin") @@ -40,180 +59,177 @@ export default function AdminPage() { if (user?.role !== "super_admin") { return (
- -

Access Denied

-

Super admin access required.

+ +

Access Denied

+

Super admin access required.

) } - if (loading) return
- if (error || !data) return

{error}

+ if (loading) return
+ if (error || !data) return

{error}

const p = data.platform return (
+ + {/* Header */}
- +
+ +
-

Super Admin

-

Platform-wide view · {user?.email}

+

Platform Admin

+

{user?.email}

{/* Platform stats */} -
- {[ - { label: "Orgs", value: p.orgs, icon: Building2, color: "text-trust-blue" }, - { label: "Users", value: p.users, icon: Users, color: "text-warm-amber" }, - { label: "Events", value: p.events, icon: Calendar, color: "text-purple-500" }, - { label: "Pledges", value: p.pledges, icon: TrendingUp, color: "text-success-green" }, - { label: "Pledged", value: fmt(p.totalPledgedPence), icon: Banknote, color: "text-trust-blue" }, - { label: "Collected", value: fmt(p.totalCollectedPence), icon: Banknote, color: "text-success-green" }, - { label: "Rate", value: `${p.collectionRate}%`, icon: TrendingUp, color: p.collectionRate > 50 ? "text-success-green" : "text-warm-amber" }, - ].map(s => ( - - -
- - {s.label} -
-

{s.value}

-
-
- ))} +
+

Platform overview

+
+ {[ + { value: String(p.orgs), label: "Charities" }, + { value: String(p.users), label: "Users" }, + { value: String(p.events), label: "Appeals" }, + { value: String(p.pledges), label: "Pledges" }, + { value: formatPence(p.totalPledgedPence), label: "Promised" }, + { value: formatPence(p.totalCollectedPence), label: "Received", color: "text-[#4ADE80]" }, + { value: `${p.collectionRate}%`, label: "Rate", color: p.collectionRate >= 50 ? "text-[#4ADE80]" : "text-[#FBBF24]" }, + ].map(s => ( +
+

{s.value}

+

{s.label}

+
+ ))} +
{/* Pipeline */} - - - Pipeline - - -
- {Object.entries(data.byStatus).map(([status, { count, amount }]) => ( -
- {status} -

{count}

-

{fmt(amount)}

-
- ))} +
+ {Object.entries(data.byStatus).map(([status, { count, amount }]) => { + const sl = STATUS[status] || STATUS.new + return ( +
+ {sl.label} +

{count}

+

{formatPence(amount)}

+
+ ) + })} +
+ + {/* Tab switcher */} +
+ {[ + { key: "orgs" as const, label: `Charities (${data.orgs.length})`, icon: Building2 }, + { key: "users" as const, label: `Users (${data.users.length})`, icon: Users }, + { key: "pledges" as const, label: "Recent Pledges", icon: Banknote }, + ].map(t => ( + + ))} +
+ + {/* ── Orgs tab ── */} + {tab === "orgs" && ( +
+
+
Charity
+
Bank
+
Users
+
Appeals
+
Pledges
+
Created
- - + {data.orgs.map(o => ( +
+
+

{o.name}

+

{o.slug}

+
+
+ + {o.hasBank ? "Set" : "Missing"} + +
+
{o.users}
+
{o.events}
+
{o.pledges}
+
{new Date(o.createdAt).toLocaleDateString("en-GB", { day: "numeric", month: "short" })}
+
+ ))} + {data.orgs.length === 0 &&
No charities yet
} +
+ )} - - - Organisations ({data.orgs.length}) - Users ({data.users.length}) - Recent Pledges - + {/* ── Users tab ── */} + {tab === "users" && ( +
+
+
Email
+
Name
+
Role
+
Charity
+
Joined
+
+ {data.users.map(u => ( +
+
{u.email}
+
{u.name || "—"}
+
+ + {ROLE_LABELS[u.role] || u.role} + +
+
{u.orgName}
+
{new Date(u.createdAt).toLocaleDateString("en-GB", { day: "numeric", month: "short" })}
+
+ ))} +
+ )} - - - - - - - Name - Bank - Users - Events - Pledges - Created - - - - {data.orgs.map(o => ( - - -

{o.name}

-

{o.slug}

-
- - {o.hasBank ? ✓ Set : Missing} - - {o.users} - {o.events} - {o.pledges} - {new Date(o.createdAt).toLocaleDateString("en-GB", { day: "numeric", month: "short" })} -
- ))} -
-
-
-
-
- - - - - - - - Email - Name - Role - Organisation - Joined - - - - {data.users.map(u => ( - - {u.email} - {u.name || "—"} - - - {u.role === "super_admin" ? "🛡️ Super" : u.role === "org_admin" ? "Admin" : u.role} - - - {u.orgName} - {new Date(u.createdAt).toLocaleDateString("en-GB", { day: "numeric", month: "short" })} - - ))} - -
-
-
-
- - - - - - - - Reference - Donor - Amount - Status - Event - Org - Date - - - - {data.recentPledges.map(p => ( - - {p.reference} - {p.donorName || "Anon"} - {fmt(p.amountPence)} - - {p.status} - - {p.eventName} - {p.orgName} - {new Date(p.createdAt).toLocaleDateString("en-GB", { day: "numeric", month: "short" })} - - ))} - -
-
-
-
-
+ {/* ── Recent pledges tab ── */} + {tab === "pledges" && ( +
+
+
Reference
+
Donor
+
Amount
+
Status
+
Appeal
+
Charity
+
Date
+
+ {data.recentPledges.map(pledge => { + const sl = STATUS[pledge.status] || STATUS.new + return ( +
+
{pledge.reference}
+
{pledge.donorName || "Anon"}
+
{formatPence(pledge.amountPence)}
+
{sl.label}
+
{pledge.eventName}
+
{pledge.orgName}
+
{new Date(pledge.createdAt).toLocaleDateString("en-GB", { day: "numeric", month: "short" })}
+
+ ) + })} +
+ )}
) } diff --git a/pledge-now-pay-later/src/app/dashboard/community/page.tsx b/pledge-now-pay-later/src/app/dashboard/community/page.tsx new file mode 100644 index 0000000..4d4f31c --- /dev/null +++ b/pledge-now-pay-later/src/app/dashboard/community/page.tsx @@ -0,0 +1,350 @@ +"use client" + +import { useState, useEffect, useCallback } from "react" +import { useSession } from "next-auth/react" +import { formatPence } from "@/lib/utils" +import { + Loader2, Copy, Check, MessageCircle, Mail, Trophy, + Plus, Link2, Download, QrCode as QrCodeIcon +} from "lucide-react" +import { QRCodeCanvas } from "@/components/qr-code" + +/** + * /dashboard/community — Community Leader's dashboard + * + * Who is the Community Leader? + * Imam Yusuf. Sister Mariam. Uncle Tariq. The person who rallies + * their mosque, WhatsApp group, neighbourhood to pledge. + * + * Their mental model: + * 1. "How are WE doing?" → their community's stats vs the whole appeal + * 2. "I need to share the link" → share buttons, front and center + * 3. "Who from my group has pledged?" → simple donor list + * 4. "How do we compare?" → leaderboard across all communities + * + * What they DON'T see: + * - Bank details, WhatsApp settings, charity config + * - Other communities' donor details + * - Ability to confirm payments or change statuses + * - Full reconciliation + * + * This is a SCOPED, READ-MOSTLY view. + * They can create new links and share. That's it. + */ + +interface EventSummary { + id: string; name: string; totalPledged: number; totalCollected: number; pledgeCount: number +} + +interface SourceInfo { + id: string; label: string; code: string; volunteerName: string | null + scanCount: number; pledgeCount: number; totalPledged: number; totalCollected?: number +} + +interface PledgeInfo { + id: string; donorName: string | null; amountPence: number; status: string; createdAt: string +} + +const STATUS: Record = { + new: { label: "Waiting", color: "text-gray-600", bg: "bg-gray-100" }, + initiated: { label: "Said paid", color: "text-[#F59E0B]", bg: "bg-[#F59E0B]/10" }, + paid: { label: "Received ✓", color: "text-[#16A34A]", bg: "bg-[#16A34A]/10" }, + overdue: { label: "Overdue", color: "text-[#DC2626]", bg: "bg-[#DC2626]/10" }, + cancelled: { label: "Cancelled", color: "text-gray-400", bg: "bg-gray-50" }, +} + +export default function CommunityPage() { + const { data: session } = useSession() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const user = session?.user as any + + const [events, setEvents] = useState([]) + const [allSources, setAllSources] = useState([]) + const [mySources, setMySources] = useState([]) + const [myPledges, setMyPledges] = useState([]) + const [loading, setLoading] = useState(true) + const [copiedCode, setCopiedCode] = useState(null) + const [showQr, setShowQr] = useState(null) + + // Create link + const [newLinkName, setNewLinkName] = useState("") + const [creating, setCreating] = useState(false) + const [showCreate, setShowCreate] = useState(false) + + const baseUrl = typeof window !== "undefined" ? window.location.origin : "" + const userName = user?.name || user?.email?.split("@")[0] || "" + + const loadData = useCallback(async () => { + try { + const [evRes, dashRes] = await Promise.all([ + fetch("/api/events").then(r => r.json()), + fetch("/api/dashboard").then(r => r.json()), + ]) + + if (Array.isArray(evRes)) { + setEvents(evRes) + // Load sources for first event + if (evRes.length > 0) { + const srcRes = await fetch(`/api/events/${evRes[0].id}/qr`).then(r => r.json()) + if (Array.isArray(srcRes)) { + setAllSources(srcRes) + // Filter to "my" sources — those with this user's name or created by this user + const mine = srcRes.filter((s: SourceInfo) => + s.volunteerName?.toLowerCase().includes(userName.toLowerCase()) || + s.label.toLowerCase().includes(userName.toLowerCase()) + ) + setMySources(mine.length > 0 ? mine : srcRes.slice(0, 3)) // fallback: show first 3 + } + } + } + + // Get pledges (scoped view — show recent) + if (dashRes.pledges) { + setMyPledges(dashRes.pledges.slice(0, 20)) + } + } catch { /* */ } + setLoading(false) + }, [userName]) + + useEffect(() => { loadData() }, [loadData]) + + // Actions + const copyLink = async (code: string) => { + await navigator.clipboard.writeText(`${baseUrl}/p/${code}`) + setCopiedCode(code) + setTimeout(() => setCopiedCode(null), 2000) + } + + const shareWhatsApp = (code: string, label: string) => { + window.open(`https://wa.me/?text=${encodeURIComponent(`Assalamu Alaikum! Please pledge here 🤲\n\n${label}\n${baseUrl}/p/${code}`)}`, "_blank") + } + + const createLink = async () => { + if (!newLinkName.trim() || events.length === 0) return + setCreating(true) + try { + const res = await fetch(`/api/events/${events[0].id}/qr`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ label: newLinkName.trim(), volunteerName: userName }), + }) + if (res.ok) { + const src = await res.json() + const newSrc = { ...src, scanCount: 0, pledgeCount: 0, totalPledged: 0, totalCollected: 0 } + setMySources(prev => [newSrc, ...prev]) + setAllSources(prev => [newSrc, ...prev]) + setNewLinkName("") + setShowCreate(false) + } + } catch { /* */ } + setCreating(false) + } + + if (loading) return
+ + const activeEvent = events[0] + const myTotal = mySources.reduce((s, l) => s + l.totalPledged, 0) + const myPledgeCount = mySources.reduce((s, l) => s + l.pledgeCount, 0) + const appealTotal = activeEvent?.totalPledged || 0 + const myPct = appealTotal > 0 ? Math.round((myTotal / appealTotal) * 100) : 0 + + // Leaderboard (all sources, sorted by amount) + const leaderboard = [...allSources].sort((a, b) => b.totalPledged - a.totalPledged) + + return ( +
+ + {/* ── Header: "How are WE doing?" ── */} +
+

+ {activeEvent?.name || "Your community"} +

+

+ Welcome back, {userName.split(" ")[0] || "Leader"} +

+
+ + {/* ── My community's stats vs the whole appeal ── */} +
+
+
+

Your community

+

{formatPence(myTotal)}

+

{myPledgeCount} pledges from {mySources.length} links

+
+
+

Whole appeal

+

{formatPence(appealTotal)}

+

{activeEvent?.pledgeCount || 0} total pledges

+
+
+ {appealTotal > 0 && ( +
+
+ Your contribution + {myPct}% +
+
+
+
+
+ )} +
+ + {/* ── Your links + share ── */} +
+

Your links ({mySources.length})

+ +
+ + {showCreate && ( +
+

Create a link for your community

+
+ setNewLinkName(e.target.value)} + placeholder='e.g. "Friday Halaqa", "Sisters WhatsApp", "Youth Group"' + autoFocus + onKeyDown={e => e.key === "Enter" && createLink()} + className="flex-1 h-11 px-4 border-2 border-gray-200 text-sm placeholder:text-gray-300 focus:border-[#1E40AF] outline-none" + /> + +
+ +
+ )} + + {mySources.length === 0 ? ( +
+ +

No links yet

+

Create a link to start collecting pledges for your community

+
+ ) : ( +
+ {mySources.map(src => { + const url = `${baseUrl}/p/${src.code}` + const isCopied = copiedCode === src.code + const isQrOpen = showQr === src.code + + return ( +
+
+
+

{src.label}

+
+
+

{src.pledgeCount}

+

pledges

+
+
+

{formatPence(src.totalPledged)}

+

raised

+
+
+
+ +
+

{url}

+
+ +
+ + + + +
+ + {isQrOpen && ( + + )} +
+
+ ) + })} +
+ )} + + {/* ── Leaderboard — how do we compare? ── */} + {leaderboard.filter(s => s.pledgeCount > 0).length >= 2 && ( +
+
+

+ How communities compare +

+
+
+ {leaderboard.filter(s => s.pledgeCount > 0).slice(0, 10).map((src, i) => { + const isMine = mySources.some(m => m.id === src.id) + const medals = ["bg-[#F59E0B]", "bg-gray-400", "bg-[#CD7F32]"] + return ( +
+
+ {i + 1} +
+
+
+

{src.volunteerName || src.label}

+ {isMine && You} +
+

{src.pledgeCount} pledges

+
+

{formatPence(src.totalPledged)}

+
+ ) + })} +
+
+ )} + + {/* ── Recent pledges (read-only, no actions) ── */} + {myPledges.length > 0 && ( +
+
+

Recent pledges

+
+
+ {myPledges.slice(0, 10).map(p => { + const sl = STATUS[p.status] || STATUS.new + const days = Math.floor((Date.now() - new Date(p.createdAt).getTime()) / 86400000) + const when = days === 0 ? "Today" : days === 1 ? "Yesterday" : `${days}d ago` + return ( +
+
+

{p.donorName || "Anonymous"}

+

{when}

+
+
+

{formatPence(p.amountPence)}

+ {sl.label} +
+
+ ) + })} +
+
+ )} +
+ ) +} diff --git a/pledge-now-pay-later/src/app/dashboard/layout.tsx b/pledge-now-pay-later/src/app/dashboard/layout.tsx index e211833..fddb384 100644 --- a/pledge-now-pay-later/src/app/dashboard/layout.tsx +++ b/pledge-now-pay-later/src/app/dashboard/layout.tsx @@ -4,18 +4,16 @@ import Link from "next/link" import { usePathname } from "next/navigation" import { useSession, signOut } from "next-auth/react" import { useState, useEffect } from "react" -import { Home, Megaphone, Banknote, FileText, Settings, Plus, LogOut, Shield, AlertTriangle, MessageCircle } from "lucide-react" +import { Home, Megaphone, Banknote, FileText, Settings, Plus, LogOut, Shield, AlertTriangle, MessageCircle, Users } from "lucide-react" import { cn } from "@/lib/utils" /** * Navigation: goal-oriented, not feature-oriented - * "Home" — where am I at? - * "Collect" — I want people to pledge - * "Money" — where's the money? - * "Reports" — my treasurer needs numbers - * "Settings" — connect WhatsApp, bank details + * Different nav for different roles: + * - Admin/Staff: Full nav (Home, Collect, Money, Reports, Settings) + * - Community Leader: Scoped nav (My Community, Collect, Reports) */ -const navItems = [ +const adminNavItems = [ { href: "/dashboard", label: "Home", icon: Home }, { href: "/dashboard/collect", label: "Collect", icon: Megaphone }, { href: "/dashboard/money", label: "Money", icon: Banknote }, @@ -23,7 +21,13 @@ const navItems = [ { href: "/dashboard/settings", label: "Settings", icon: Settings }, ] -const adminNav = { href: "/dashboard/admin", label: "Super Admin", icon: Shield } +const communityNavItems = [ + { href: "/dashboard/community", label: "My Community", icon: Users }, + { href: "/dashboard/collect", label: "Share Links", icon: Megaphone }, + { href: "/dashboard/reports", label: "Reports", icon: FileText }, +] + +const superAdminNav = { href: "/dashboard/admin", label: "Platform Admin", icon: Shield } export default function DashboardLayout({ children }: { children: React.ReactNode }) { const pathname = usePathname() @@ -31,9 +35,13 @@ export default function DashboardLayout({ children }: { children: React.ReactNod // eslint-disable-next-line @typescript-eslint/no-explicit-any const user = session?.user as any + const isCommunityLeader = user?.role === "community_leader" + const navItems = isCommunityLeader ? communityNavItems : adminNavItems + // Map old routes to new ones for active state const isActive = (href: string) => { if (href === "/dashboard") return pathname === "/dashboard" + if (href === "/dashboard/community") return pathname === "/dashboard/community" || pathname === "/dashboard" if (href === "/dashboard/collect") return pathname.startsWith("/dashboard/collect") || pathname.startsWith("/dashboard/events") if (href === "/dashboard/money") return pathname.startsWith("/dashboard/money") || pathname.startsWith("/dashboard/pledges") || pathname.startsWith("/dashboard/reconcile") if (href === "/dashboard/reports") return pathname.startsWith("/dashboard/reports") || pathname.startsWith("/dashboard/exports") @@ -54,11 +62,13 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
- - - + {!isCommunityLeader && ( + + + + )} {session && ( +
+

People who can access your dashboard. Invite community leaders to track their pledges.

+
+ + {/* Invite form */} + {showInvite && !inviteResult && ( +
+

Invite a team member

+
+
+ + setInviteEmail(e.target.value)} placeholder="imam@mosque.org" className="w-full h-9 px-3 border-2 border-gray-200 text-sm focus:border-[#1E40AF] outline-none" /> +
+
+ + setInviteName(e.target.value)} placeholder="Imam Yusuf" className="w-full h-9 px-3 border-2 border-gray-200 text-sm focus:border-[#1E40AF] outline-none" /> +
+
+
+ +
+ {Object.entries(ROLE_LABELS).filter(([k]) => k !== "org_admin" || currentUser?.role === "super_admin").map(([key, r]) => ( + + ))} +
+
+
+ + +
+
+ )} + + {/* Invite result — show credentials once */} + {inviteResult && ( +
+
+ +

Invited!

+
+

Share these login details with them. The password is shown only once.

+
+

Email: {inviteResult.email}

+

Password: {inviteResult.tempPassword}

+

Login: {typeof window !== "undefined" ? window.location.origin : ""}/login

+
+
+ + +
+ +
+ )} + + {/* Team list */} +
+ {team.map(m => { + const r = ROLE_LABELS[m.role] || ROLE_LABELS.staff + const isCurrentUser = m.id === currentUser?.id + const RoleIcon = r.icon + return ( +
+
+ +
+
+
+

{m.name || m.email}

+ {isCurrentUser && You} +
+

{m.email}

+
+
+ {isAdmin && !isCurrentUser ? ( + + ) : ( + {r.label} + )} + {isAdmin && !isCurrentUser && ( + + )} +
+
+ ) + })} +
+
+ )} + + {/* ── 3. Bank account ── */}

Bank account

-

These details are shown to donors so they can transfer money to you. Each pledge gets a unique reference code.

+

Shown to donors so they know where to transfer. Each pledge gets a unique reference.

update("bankName", e.target.value)} placeholder="e.g. Barclays" />
@@ -80,41 +309,18 @@ export default function SettingsPage() {
update("bankAccountNo", e.target.value)} placeholder="12345678" />
- + update("refPrefix", e.target.value)} maxLength={4} className="w-24" /> -

Donors will see references like {settings.refPrefix}-XXXX-50

+

Donors see references like {settings.refPrefix}-XXXX-50

- {/* Direct Debit */} -
-
-

Direct Debit

-

Accept Direct Debit payments via GoCardless. Donors set up a mandate and payments are collected automatically.

-
-
- - update("gcAccessToken", e.target.value)} placeholder="sandbox_xxxxx or live_xxxxx" /> -
-
- -
- {["sandbox", "live"].map(env => ( - - ))} -
-
- -
- - {/* Branding */} + {/* ── 4. Charity details ── */}

Your charity

-

This name and colour appear on pledge pages and WhatsApp messages.

+

Name and colour shown on pledge pages and WhatsApp messages.

update("name", e.target.value)} />
@@ -126,11 +332,36 @@ export default function SettingsPage() {
+ + {/* ── 5. Direct Debit (collapsed) ── */} +
+ + Direct Debit GoCardless integration + +
+

Accept Direct Debit payments via GoCardless. Donors set up a mandate and payments are collected automatically.

+
+ + update("gcAccessToken", e.target.value)} placeholder="sandbox_xxxxx or live_xxxxx" /> +
+
+ +
+ {["sandbox", "live"].map(env => ( + + ))} +
+
+ +
+
) } -// ─── WhatsApp Connection Panel ─────────────────────────────── +// ─── WhatsApp Connection Panel (unchanged) ─────────────────── function WhatsAppPanel() { const [status, setStatus] = useState("loading") @@ -167,10 +398,7 @@ function WhatsAppPanel() {
-
-

{pushName || "WhatsApp"}

-

+{phone}

-
+

{pushName || "WhatsApp"}

+{phone}

@@ -178,12 +406,7 @@ function WhatsAppPanel() { { label: "Receipts", desc: "Auto-sends when someone pledges" }, { label: "Reminders", desc: "4-step reminder sequence" }, { label: "Chatbot", desc: "Donors reply PAID, HELP, etc." }, - ].map(f => ( -
-

{f.label}

-

{f.desc}

-
- ))} + ].map(f => (

{f.label}

{f.desc}

))}
) @@ -194,7 +417,7 @@ function WhatsAppPanel() {

WhatsApp

- Scan QR code + Scan QR
{qrImage ? ( @@ -203,18 +426,13 @@ function WhatsAppPanel() { WhatsApp QR Code
) : ( -
- -
+
)}

Scan with your phone

-

Open WhatsApp → Settings → Linked Devices → Link a Device

-

Auto-refreshes every 5 seconds

+

WhatsApp → Settings → Linked Devices → Link a Device

- +
) @@ -233,15 +451,11 @@ function WhatsAppPanel() {

• Pledge receipts with bank details

• Payment reminders on a 4-step schedule

• A chatbot (they reply PAID, HELP, or CANCEL)

-

• Volunteer notifications on each pledge

-

- Free — no WhatsApp Business API required -

) } diff --git a/temp_files/v3/AppealResource.php b/temp_files/v3/AppealResource.php new file mode 100644 index 0000000..ebd94cd --- /dev/null +++ b/temp_files/v3/AppealResource.php @@ -0,0 +1,429 @@ +name; + } + + public static function getGlobalSearchResultDetails(Model $record): array + { + $pct = $record->amount_to_raise > 0 + ? round($record->amount_raised / $record->amount_to_raise * 100) + : 0; + + return [ + 'Progress' => '£' . number_format($record->amount_raised / 100, 0) . ' / £' . number_format($record->amount_to_raise / 100, 0) . " ({$pct}%)", + 'Status' => $record->is_accepting_donations ? 'Live' : 'Closed', + 'Owner' => $record->user?->name ?? '—', + ]; + } + + public static function getGlobalSearchResultActions(Model $record): array + { + return [ + GlobalSearchAction::make('edit') + ->label('Open Fundraiser') + ->url(static::getUrl('edit', ['record' => $record])), + ]; + } + + public static function getGlobalSearchEloquentQuery(): Builder + { + return parent::getGlobalSearchEloquentQuery() + ->with('user') + ->latest('created_at'); + } + + // ─── Form ──────────────────────────────────────────────────── + + public static function form(Form $form): Form + { + return $form + ->schema([ + Fieldset::make('Connections')->schema([ + Select::make('parent_appeal_id') + ->label('Parent Fundraiser') + ->relationship('parent', 'name', modifyQueryUsing: fn ($query) => $query->whereNull('parent_appeal_id')) + ->required(false) + ->searchable(), + + Select::make('user_id') + ->label('Page Owner') + ->searchable() + ->relationship('user', 'name') + ->required(), + ]), + + Section::make('General Info')->schema([ + Fieldset::make('Info')->schema([ + TextInput::make('name') + ->label('Fundraiser Name') + ->required() + ->maxLength(255), + + TextInput::make('slug') + ->label('URL Slug') + ->required() + ->maxLength(255) + ->unique( + table: 'appeals', + column: 'slug', + ignorable: $form->getRecord(), + modifyRuleUsing: fn ($rule) => $rule->whereNull('deleted_at') + ) + ->disabled(), + + TextInput::make('description') + ->label('Short Description') + ->required() + ->maxLength(512), + ]), + + Fieldset::make('Target & Allocation')->schema([ + TextInput::make('amount_to_raise') + ->label('Fundraising Target') + ->numeric() + ->minValue(150) + ->maxValue(999_999_999) + ->prefix('£') + ->required(), + + TextInput::make('amount_raised') + ->label('Amount Raised So Far') + ->numeric() + ->prefix('£') + ->disabled(), + + Select::make('donation_type_id') + ->label('Cause') + ->relationship('donationType', 'display_name') + ->searchable() + ->preload() + ->live() + ->required(), + + Select::make('donation_country_id') + ->label('Country') + ->required() + ->visible(function (\Filament\Forms\Get $get) { + $donationTypeId = $get('donation_type_id'); + if (!($donationType = DonationType::find($donationTypeId))) { + return false; + } + return $donationType->donationCountries()->count() > 1; + }) + ->options(function (\Filament\Forms\Get $get) { + $donationTypeId = $get('donation_type_id'); + if (!($donationType = DonationType::find($donationTypeId))) { + return []; + } + return $donationType->donationCountries->pluck('name', 'id')->toArray(); + }) + ->live(), + ]), + + Fieldset::make('Settings')->schema([ + Toggle::make('is_visible') + ->label('Visible on website'), + + Toggle::make('is_in_memory') + ->label('In memory of someone') + ->live(), + + TextInput::make('in_memory_name') + ->label('In memory of') + ->required(fn (\Filament\Forms\Get $get) => $get('is_in_memory')) + ->visible(fn (\Filament\Forms\Get $get) => $get('is_in_memory')), + + Toggle::make('is_accepting_donations') + ->label('Accepting donations'), + + Toggle::make('is_team_campaign') + ->label('Team fundraiser'), + + Toggle::make('is_accepting_members') + ->label('Accepting team members') + ->live() + ->visible(fn (\Filament\Forms\Get $get) => $get('is_team_campaign')), + ]), + ]) + ->collapsible() + ->collapsed(), + + Section::make('Content')->schema([ + FileUpload::make('picture') + ->label('Cover Image') + ->required() + ->columnSpanFull(), + + RichEditor::make('story') + ->label('Fundraiser Story') + ->required() + ->minLength(150) + ->columnSpanFull(), + ]) + ->collapsible() + ->collapsed(), + ]); + } + + // ─── Table ──────────────────────────────────────────────────── + // Designed for the "Fundraiser Nurture" journey: + // Jasmine opens this page and immediately sees which fundraisers + // need attention, which are succeeding, which are stale. + + public static function table(Table $table): Table + { + return $table + ->columns([ + TextColumn::make('name') + ->label('Fundraiser') + ->searchable() + ->sortable() + ->weight('bold') + ->limit(40) + ->description(fn (Appeal $a) => $a->user?->name ? 'by ' . $a->user->name : null) + ->tooltip(fn (Appeal $a) => $a->name), + + TextColumn::make('status_label') + ->label('Status') + ->getStateUsing(function (Appeal $a) { + if ($a->status === 'pending') return 'Pending Review'; + if (!$a->is_accepting_donations) return 'Closed'; + return 'Live'; + }) + ->badge() + ->color(fn ($state) => match ($state) { + 'Live' => 'success', + 'Pending Review' => 'warning', + 'Closed' => 'gray', + default => 'gray', + }), + + TextColumn::make('progress') + ->label('Progress') + ->getStateUsing(function (Appeal $a) { + $raised = $a->amount_raised / 100; + $target = $a->amount_to_raise / 100; + $pct = $target > 0 ? round($raised / $target * 100) : 0; + return '£' . number_format($raised, 0) . ' / £' . number_format($target, 0) . " ({$pct}%)"; + }) + ->color(function (Appeal $a) { + $pct = $a->amount_to_raise > 0 ? $a->amount_raised / $a->amount_to_raise : 0; + if ($pct >= 1) return 'success'; + if ($pct >= 0.5) return 'info'; + if ($pct > 0) return 'warning'; + return 'danger'; + }) + ->weight('bold'), + + TextColumn::make('donationType.display_name') + ->label('Cause') + ->badge() + ->color('info') + ->toggleable(), + + TextColumn::make('nurture_status') + ->label('Needs Attention?') + ->getStateUsing(function (Appeal $a) { + if ($a->status !== 'confirmed') return null; + if (!$a->is_accepting_donations) return null; + + $raised = $a->amount_raised; + $target = $a->amount_to_raise; + $age = $a->created_at?->diffInDays(now()) ?? 0; + + if ($raised == 0 && $age > 7) return '🔴 No donations yet'; + if ($raised > 0 && $raised >= $target * 0.8 && $raised < $target) return '🟡 Almost there!'; + if ($raised >= $target) return '🟢 Target reached!'; + if ($raised > 0 && $age > 30) return '🟠 Slowing down'; + + return null; + }) + ->placeholder('—') + ->wrap(), + + TextColumn::make('created_at') + ->label('Created') + ->since() + ->sortable() + ->description(fn (Appeal $a) => $a->created_at?->format('d M Y')), + ]) + ->filters([ + SelectFilter::make('nurture_segment') + ->label('Nurture Segment') + ->options([ + 'needs_outreach' => '🔴 Needs Outreach (£0 raised, 7+ days)', + 'almost_there' => '🟡 Almost There (80%+ of target)', + 'target_reached' => '🟢 Target Reached', + 'slowing' => '🟠 Slowing Down (raised something, 30+ days)', + 'new_this_week' => '🆕 New This Week', + ]) + ->query(function (Builder $query, array $data) { + if (!$data['value']) return; + $query->where('status', 'confirmed')->where('is_accepting_donations', true); + + match ($data['value']) { + 'needs_outreach' => $query->where('amount_raised', 0)->where('created_at', '<', now()->subDays(7)), + 'almost_there' => $query->where('amount_raised', '>', 0)->whereRaw('amount_raised >= amount_to_raise * 0.8')->whereRaw('amount_raised < amount_to_raise'), + 'target_reached' => $query->whereRaw('amount_raised >= amount_to_raise')->where('amount_raised', '>', 0), + 'slowing' => $query->where('amount_raised', '>', 0)->whereRaw('amount_raised < amount_to_raise * 0.8')->where('created_at', '<', now()->subDays(30)), + 'new_this_week' => $query->where('created_at', '>=', now()->subDays(7)), + default => null, + }; + }), + + SelectFilter::make('status') + ->options([ + 'confirmed' => 'Live', + 'pending' => 'Pending Review', + ]), + + Filter::make('accepting_donations') + ->label('Currently accepting donations') + ->toggle() + ->query(fn (Builder $q) => $q->where('is_accepting_donations', true)) + ->default(), + + Filter::make('has_raised') + ->label('Has raised money') + ->toggle() + ->query(fn (Builder $q) => $q->where('amount_raised', '>', 0)), + ], layout: FiltersLayout::AboveContentCollapsible) + ->actions([ + Action::make('view_page') + ->label('View Page') + ->icon('heroicon-o-eye') + ->color('gray') + ->url(fn (Appeal $a) => 'https://www.charityright.org.uk/fundraiser/' . $a->slug) + ->openUrlInNewTab(), + + ActionGroup::make([ + EditAction::make(), + + Action::make('email_owner') + ->label('Email Owner') + ->icon('heroicon-o-envelope') + ->url(fn (Appeal $a) => $a->user?->email ? 'mailto:' . $a->user->email : null) + ->visible(fn (Appeal $a) => (bool) $a->user?->email) + ->openUrlInNewTab(), + + Action::make('send_to_engage') + ->label('Send to Engage') + ->icon('heroicon-o-arrow-up-on-square') + ->action(function (Appeal $appeal) { + dispatch(new SendAppeal($appeal)); + Notification::make()->title('Sent to Engage')->success()->send(); + }), + + Action::make('appeal_owner') + ->label('Page Owner Details') + ->icon('heroicon-o-user') + ->modalContent(fn (Appeal $appeal): View => view('filament.fields.appeal-owner', ['appeal' => $appeal])) + ->modalWidth(MaxWidth::Large) + ->modalSubmitAction(false) + ->modalCancelAction(false), + ]), + ]) + ->bulkActions([ + BulkAction::make('send_to_engage') + ->label('Send to Engage') + ->icon('heroicon-o-arrow-up-on-square') + ->action(function ($records) { + foreach ($records as $appeal) { + dispatch(new SendAppeal($appeal)); + } + Notification::make()->title(count($records) . ' sent to Engage')->success()->send(); + }), + + BulkAction::make('send_to_wp') + ->label('Sync to WordPress') + ->icon('heroicon-o-globe-alt') + ->action(function ($records) { + foreach ($records as $appeal) { + dispatch(new SyncAppeal($appeal)); + } + Notification::make()->title(count($records) . ' synced to WordPress')->success()->send(); + }), + ]) + ->defaultSort('created_at', 'desc') + ->searchPlaceholder('Search by fundraiser name...'); + } + + public static function getRelations(): array + { + return [ + AppealDonationsRelationManager::class, + AppealChildrenRelationManager::class, + ]; + } + + public static function getPages(): array + { + return [ + 'index' => ListAppeals::route('/'), + 'edit' => EditAppeal::route('/{record}/edit'), + ]; + } +} diff --git a/temp_files/v3/DonationResource.php b/temp_files/v3/DonationResource.php new file mode 100644 index 0000000..c5bbb9c --- /dev/null +++ b/temp_files/v3/DonationResource.php @@ -0,0 +1,493 @@ +isConfirmed() ? '✓' : '✗'; + return $status . ' £' . number_format($record->amount / 100, 2) . ' — ' . ($record->customer?->name ?? 'Unknown donor'); + } + + public static function getGlobalSearchResultDetails(Model $record): array + { + return [ + 'Cause' => $record->donationType?->display_name ?? '—', + 'Date' => $record->created_at?->format('d M Y H:i'), + 'Ref' => $record->reference_code ?? '—', + 'Status' => $record->isConfirmed() ? 'Confirmed' : 'Incomplete', + ]; + } + + public static function getGlobalSearchResultActions(Model $record): array + { + return [ + GlobalSearchAction::make('edit') + ->label('View Donation') + ->url(static::getUrl('edit', ['record' => $record])), + ]; + } + + public static function getGlobalSearchEloquentQuery(): Builder + { + return parent::getGlobalSearchEloquentQuery() + ->with(['customer', 'donationType', 'donationConfirmation']) + ->latest('created_at'); + } + + public static function getGlobalSearchResultsLimit(): int + { + return 8; + } + + // ─── Form (Edit/View screen) ───────────────────────────────── + + public static function form(Form $form): Form + { + return $form + ->schema([ + Section::make('Donation Details') + ->collapsible() + ->schema([ + Grid::make(5)->schema([ + Placeholder::make('Confirmed?') + ->content(fn (Donation $record) => new HtmlString( + $record->isConfirmed() + ? '✓ Confirmed' + : '✗ Incomplete' + )), + + Placeholder::make('Amount') + ->content(fn (Donation $record) => new HtmlString('' . Helpers::formatMoneyGlobal($record->donationTotal()) . '')), + + Placeholder::make('Admin Contribution') + ->content(fn (Donation $record) => new HtmlString('' . Helpers::formatMoneyGlobal($record->donationAdminAmount()) . '')), + + Placeholder::make('Gift Aid?') + ->content(fn (Donation $record) => new HtmlString( + $record->isGiftAid() + ? '✓ Yes' + : 'No' + )), + + Placeholder::make('Zakat?') + ->content(fn (Donation $record) => new HtmlString( + $record->isZakat() + ? '✓ Yes' + : 'No' + )), + ]), + + Fieldset::make('Donor') + ->columns(3) + ->schema([ + Placeholder::make('name') + ->content(fn (Donation $donation) => $donation->customer?->name ?? '—'), + + Placeholder::make('email') + ->content(fn (Donation $donation) => $donation->customer?->email ?? '—'), + + Placeholder::make('phone') + ->content(fn (Donation $donation) => strlen(trim($phone = $donation->customer?->phone ?? '')) > 0 ? $phone : new HtmlString('Not provided')), + ]) + ->visible(fn (Donation $donation) => (bool) $donation->customer), + + Fieldset::make('Address') + ->columns(3) + ->schema([ + Placeholder::make('house') + ->content(fn (Donation $donation) => strlen(trim($v = $donation->address?->house ?? '')) ? $v : new HtmlString('')), + + Placeholder::make('street') + ->content(fn (Donation $donation) => strlen(trim($v = $donation->address?->street ?? '')) ? $v : new HtmlString('')), + + Placeholder::make('town') + ->content(fn (Donation $donation) => strlen(trim($v = $donation->address?->town ?? '')) ? $v : new HtmlString('')), + + Placeholder::make('state') + ->content(fn (Donation $donation) => strlen(trim($v = $donation->address?->state ?? '')) ? $v : new HtmlString('')), + + Placeholder::make('postcode') + ->content(fn (Donation $donation) => strlen(trim($v = $donation->address?->postcode ?? '')) ? $v : new HtmlString('')), + + Placeholder::make('country_code') + ->content(fn (Donation $donation) => strlen(trim($v = $donation->address?->country_code ?? '')) ? $v : new HtmlString('')), + ]) + ->visible(fn (Donation $donation) => (bool) $donation->address), + + Fieldset::make('Allocation') + ->schema([ + Select::make('donation_type_id') + ->label('Cause') + ->relationship('donationType', 'display_name') + ->required() + ->disabled(), + + Select::make('donation_country_id') + ->label('Country') + ->relationship('donationCountry', 'name') + ->required() + ->disabled(), + + Select::make('appeal_id') + ->label('Fundraiser') + ->relationship('appeal', 'name') + ->searchable() + ->live() + ->disabled() + ->visible(fn (Donation $donation) => $donation->appeal_id !== null), + + Placeholder::make('ever_give_donation') + ->content(fn (Donation $donation) => + '£' . number_format( + EverGiveDonation::where('donation_id', $donation->id)->value('amount') ?? 0, + 2 + ) + )->visible(function (Donation $donation) { + return EverGiveDonation::where('donation_id', $donation->id)->where('status', '!=', 'failed_to_send_to_ever_give_api')->first(); + }), + ]), + ]), + ]); + } + + // ─── Table ──────────────────────────────────────────────────── + // Designed for two workflows: + // 1. "Find a specific donation" (search by donor name/email/ref) + // 2. "What happened today?" (default sort, quick status scan) + + public static function table(Table $table): Table + { + return $table + ->columns([ + IconColumn::make('is_confirmed') + ->label('') + ->boolean() + ->trueIcon('heroicon-o-check-circle') + ->falseIcon('heroicon-o-x-circle') + ->trueColor('success') + ->falseColor('danger') + ->tooltip(fn (Donation $d) => $d->isConfirmed() ? 'Payment confirmed' : 'Payment incomplete — may need follow-up'), + + TextColumn::make('created_at') + ->label('Date') + ->dateTime('d M Y H:i') + ->sortable() + ->description(fn (Donation $d) => $d->created_at?->diffForHumans()), + + TextColumn::make('customer.name') + ->label('Donor') + ->description(fn (Donation $d) => $d->customer?->email) + ->searchable(query: function (Builder $query, string $search) { + $query->whereHas('customer', fn ($q) => $q + ->where('first_name', 'like', "%{$search}%") + ->orWhere('last_name', 'like', "%{$search}%") + ->orWhere('email', 'like', "%{$search}%") + ); + }) + ->sortable(), + + TextColumn::make('amount') + ->label('Amount') + ->money('gbp', divideBy: 100) + ->sortable() + ->weight('bold'), + + TextColumn::make('donationType.display_name') + ->label('Cause') + ->badge() + ->color('info') + ->toggleable(), + + TextColumn::make('reoccurrence') + ->label('Type') + ->formatStateUsing(fn ($state) => match ((int) $state) { + -1 => 'One-off', + 2 => 'Monthly', + default => DonationOccurrence::translate($state), + }) + ->badge() + ->color(fn ($state) => match ((int) $state) { + -1 => 'gray', + 2 => 'success', + default => 'warning', + }), + + TextColumn::make('appeal.name') + ->label('Fundraiser') + ->toggleable() + ->limit(25) + ->placeholder('Direct'), + + TextColumn::make('reference_code') + ->label('Ref') + ->searchable() + ->fontFamily('mono') + ->copyable() + ->toggleable(isToggledHiddenByDefault: true), + + TextColumn::make('provider_reference') + ->label('Payment Ref') + ->searchable() + ->fontFamily('mono') + ->copyable() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->filters(static::getTableFilters(), layout: FiltersLayout::AboveContentCollapsible) + ->actions(static::getTableRowActions()) + ->bulkActions(static::getBulkActions()) + ->headerActions([ + ExportAction::make('donations') + ->visible(fn () => Auth::user()?->hasRole('Superadmin') || Auth::user()?->hasPermissionTo('view-donation')) + ->label('Export') + ->icon('heroicon-o-arrow-down-on-square') + ->exporter(DonationExporter::class), + ]) + ->defaultSort('created_at', 'desc') + ->searchPlaceholder('Search by donor name, email, or reference...') + ->poll('30s'); + } + + public static function getRelations(): array + { + return [ + EventLogsRelationManager::class, + InternalNotesRelationManager::class, + ]; + } + + public static function getPages(): array + { + return [ + 'index' => ListDonations::route('/'), + 'edit' => EditDonation::route('/{record}/edit'), + ]; + } + + // ─── Table Actions ─────────────────────────────────────────── + // Quick actions that solve problems without navigating away. + // "See a failed donation? → Resend receipt. See unknown donor? → Open profile." + + private static function getTableRowActions(): array + { + return [ + Action::make('view_donor') + ->label('Donor') + ->icon('heroicon-o-user') + ->color('gray') + ->url(fn (Donation $d) => $d->customer_id + ? CustomerResource::getUrl('edit', ['record' => $d->customer_id]) + : null) + ->visible(fn (Donation $d) => (bool) $d->customer_id) + ->openUrlInNewTab(), + + ActionGroup::make([ + ViewAction::make(), + + Action::make('send_receipt') + ->label('Send Receipt') + ->icon('heroicon-o-envelope') + ->requiresConfirmation() + ->modalDescription(fn (Donation $d) => 'Send receipt to ' . ($d->customer?->email ?? '?')) + ->visible(fn (Donation $d) => $d->isConfirmed() && (Auth::user()?->hasRole('Superadmin') || Auth::user()?->hasPermissionTo('view-donation'))) + ->action(function (Donation $donation) { + try { + Mail::to($donation->customer->email)->send(new DonationConfirmed($donation)); + Notification::make()->title('Receipt sent to ' . $donation->customer->email)->success()->send(); + } catch (Throwable $e) { + Log::error($e); + Notification::make()->title('Failed to send receipt')->body($e->getMessage())->danger()->send(); + } + }), + + Action::make('view_in_stripe') + ->label('View in Stripe') + ->icon('heroicon-o-arrow-top-right-on-square') + ->visible(fn (Donation $d) => (bool) $d->provider_reference) + ->url(fn (Donation $d) => 'https://dashboard.stripe.com/search?query=' . urlencode($d->provider_reference)) + ->openUrlInNewTab(), + + Action::make('send_to_engage') + ->label('Send to Engage') + ->icon('heroicon-o-arrow-up-on-square') + ->visible(fn (Donation $d) => $d->isConfirmed() && (Auth::user()?->hasRole('Superadmin') || Auth::user()?->hasPermissionTo('view-donation'))) + ->action(function (Donation $donation) { + dispatch(new SendDonation($donation)); + Notification::make()->title('Sent to Engage')->success()->send(); + }), + + Action::make('send_to_zapier') + ->label('Send to Zapier') + ->icon('heroicon-o-arrow-up-on-square') + ->visible(fn (Donation $d) => $d->isConfirmed() && (Auth::user()?->hasRole('Superadmin') || Auth::user()?->hasPermissionTo('view-donation'))) + ->action(function (Donation $donation) { + dispatch(new SendCustomer($donation->customer)); + Notification::make()->title('Sent to Zapier')->success()->send(); + }), + ]), + ]; + } + + private static function getBulkActions() + { + return BulkActionGroup::make([ + BulkAction::make('send_receipt') + ->label('Send Receipts') + ->icon('heroicon-o-envelope') + ->requiresConfirmation() + ->visible(fn () => Auth::user()?->hasRole('Superadmin') || Auth::user()?->hasPermissionTo('view-donation')) + ->action(function ($records) { + $sent = 0; + foreach ($records as $donation) { + try { + Mail::to($donation->customer->email)->send(new DonationConfirmed($donation)); + $sent++; + } catch (Throwable $e) { + Log::error($e); + } + } + Notification::make()->title($sent . ' receipts sent')->success()->send(); + }), + + BulkAction::make('send_to_engage') + ->label('Send to Engage') + ->icon('heroicon-o-arrow-up-on-square') + ->visible(fn () => Auth::user()?->hasRole('Superadmin') || Auth::user()?->hasPermissionTo('view-donation')) + ->action(function ($records) { + foreach ($records as $donation) { + dispatch(new SendDonation($donation)); + } + Notification::make()->title(count($records) . ' sent to Engage')->success()->send(); + }), + ]); + } + + // ─── Filters ───────────────────────────────────────────────── + // Designed around real questions: + // "Show me today's incomplete donations" (investigating failures) + // "Show me Zakat donations this month" (reporting) + // "Show me donations to a specific cause" (allocation check) + + private static function getTableFilters(): array + { + return [ + TernaryFilter::make('confirmed') + ->label('Payment Status') + ->placeholder('All') + ->trueLabel('Confirmed only') + ->falseLabel('Incomplete only') + ->queries( + true: fn (Builder $q) => $q->whereHas('donationConfirmation', fn ($q2) => $q2->whereNotNull('confirmed_at')), + false: fn (Builder $q) => $q->whereDoesntHave('donationConfirmation', fn ($q2) => $q2->whereNotNull('confirmed_at')), + blank: fn (Builder $q) => $q, + ) + ->default(true), + + SelectFilter::make('donation_type_id') + ->label('Cause') + ->options(fn () => DonationType::orderBy('display_name')->pluck('display_name', 'id')) + ->searchable(), + + SelectFilter::make('donation_country_id') + ->label('Country') + ->options(fn () => DonationCountry::orderBy('name')->pluck('name', 'id')) + ->searchable(), + + Filter::make('date_range') + ->form([ + DatePicker::make('from')->label('From'), + DatePicker::make('to')->label('To'), + ]) + ->query(function (Builder $query, array $data): Builder { + return $query + ->when($data['from'], fn (Builder $q) => $q->whereDate('created_at', '>=', $data['from'])) + ->when($data['to'], fn (Builder $q) => $q->whereDate('created_at', '<=', $data['to'])); + }) + ->columns(2), + + Filter::make('is_zakat') + ->label('Zakat') + ->toggle() + ->query(fn (Builder $q) => $q->whereHas('donationPreferences', fn ($q2) => $q2->where('is_zakat', true))), + + Filter::make('is_gift_aid') + ->label('Gift Aid') + ->toggle() + ->query(fn (Builder $q) => $q->whereHas('donationPreferences', fn ($q2) => $q2->where('is_gift_aid', true))), + + Filter::make('has_fundraiser') + ->label('Via Fundraiser') + ->toggle() + ->query(fn (Builder $q) => $q->whereNotNull('appeal_id')), + ]; + } +} diff --git a/temp_files/v3/FundraiserNurtureWidget.php b/temp_files/v3/FundraiserNurtureWidget.php new file mode 100644 index 0000000..e4b57ce --- /dev/null +++ b/temp_files/v3/FundraiserNurtureWidget.php @@ -0,0 +1,127 @@ +query( + Appeal::query() + ->where('status', 'confirmed') + ->where('is_accepting_donations', true) + ->where(function ($q) { + $q->where(function ($q2) { + // Needs outreach: £0 raised, 7+ days old + $q2->where('amount_raised', 0) + ->where('created_at', '<', now()->subDays(7)) + ->where('created_at', '>', now()->subDays(90)); // Not ancient + }) + ->orWhere(function ($q2) { + // Almost there: 80%+ of target + $q2->where('amount_raised', '>', 0) + ->whereRaw('amount_raised >= amount_to_raise * 0.8') + ->whereRaw('amount_raised < amount_to_raise'); + }) + ->orWhere(function ($q2) { + // New this week + $q2->where('created_at', '>=', now()->subDays(7)); + }); + }) + ->with('user') + ->orderByRaw(" + CASE + WHEN amount_raised > 0 AND amount_raised >= amount_to_raise * 0.8 AND amount_raised < amount_to_raise THEN 1 + WHEN created_at >= NOW() - INTERVAL '7 days' THEN 2 + WHEN amount_raised = 0 AND created_at < NOW() - INTERVAL '7 days' THEN 3 + ELSE 4 + END ASC + ") + ) + ->columns([ + TextColumn::make('priority') + ->label('') + ->getStateUsing(function (Appeal $a) { + $raised = $a->amount_raised; + $target = $a->amount_to_raise; + $age = $a->created_at?->diffInDays(now()) ?? 0; + + if ($raised > 0 && $raised >= $target * 0.8 && $raised < $target) return '🟡 Almost there'; + if ($age <= 7) return '🆕 New'; + if ($raised == 0) return '🔴 Needs help'; + return '—'; + }) + ->badge() + ->color(function (Appeal $a) { + $raised = $a->amount_raised; + $target = $a->amount_to_raise; + $age = $a->created_at?->diffInDays(now()) ?? 0; + + if ($raised > 0 && $raised >= $target * 0.8) return 'warning'; + if ($age <= 7) return 'info'; + return 'danger'; + }), + + TextColumn::make('name') + ->label('Fundraiser') + ->limit(35) + ->weight('bold') + ->description(fn (Appeal $a) => $a->user?->name ? 'by ' . $a->user->name : ''), + + TextColumn::make('progress') + ->label('Progress') + ->getStateUsing(function (Appeal $a) { + $raised = $a->amount_raised / 100; + $target = $a->amount_to_raise / 100; + $pct = $target > 0 ? round($raised / $target * 100) : 0; + return '£' . number_format($raised, 0) . ' / £' . number_format($target, 0) . " ({$pct}%)"; + }), + + TextColumn::make('created_at') + ->label('Created') + ->since(), + ]) + ->actions([ + Action::make('view') + ->label('Open') + ->icon('heroicon-o-arrow-right') + ->url(fn (Appeal $a) => AppealResource::getUrl('edit', ['record' => $a])) + ->color('gray'), + + Action::make('email') + ->label('Email Owner') + ->icon('heroicon-o-envelope') + ->url(fn (Appeal $a) => $a->user?->email ? 'mailto:' . $a->user->email : null) + ->visible(fn (Appeal $a) => (bool) $a->user?->email) + ->openUrlInNewTab() + ->color('info'), + ]) + ->paginated([5, 10]) + ->emptyStateHeading('All fundraisers are doing well!') + ->emptyStateDescription('No fundraisers need attention right now.') + ->emptyStateIcon('heroicon-o-face-smile'); + } +} diff --git a/temp_files/v3/ListAppeals.php b/temp_files/v3/ListAppeals.php new file mode 100644 index 0000000..589a8ec --- /dev/null +++ b/temp_files/v3/ListAppeals.php @@ -0,0 +1,40 @@ +where('is_accepting_donations', true)->count(); + $needsHelp = Appeal::where('status', 'confirmed') + ->where('is_accepting_donations', true) + ->where('amount_raised', 0) + ->where('created_at', '<', now()->subDays(7)) + ->where('created_at', '>', now()->subDays(90)) + ->count(); + $almostThere = Appeal::where('status', 'confirmed') + ->where('is_accepting_donations', true) + ->where('amount_raised', '>', 0) + ->whereRaw('amount_raised >= amount_to_raise * 0.8') + ->whereRaw('amount_raised < amount_to_raise') + ->count(); + + $parts = ["{$live} live fundraisers"]; + if ($needsHelp > 0) $parts[] = "{$needsHelp} need outreach"; + if ($almostThere > 0) $parts[] = "{$almostThere} almost at target"; + + return implode(' · ', $parts) . '. Use the "Nurture Segment" filter to find fundraisers that need your help.'; + } +} diff --git a/temp_files/v3/ListDonations.php b/temp_files/v3/ListDonations.php new file mode 100644 index 0000000..d57dcbd --- /dev/null +++ b/temp_files/v3/ListDonations.php @@ -0,0 +1,40 @@ + $q->whereNotNull('confirmed_at')) + ->whereDate('created_at', today()) + ->count(); + $todayAmount = Donation::whereHas('donationConfirmation', fn ($q) => $q->whereNotNull('confirmed_at')) + ->whereDate('created_at', today()) + ->sum('amount') / 100; + $todayIncomplete = Donation::whereDoesntHave('donationConfirmation', fn ($q) => $q->whereNotNull('confirmed_at')) + ->whereDate('created_at', today()) + ->count(); + + $parts = [ + "Today: {$todayConfirmed} confirmed (£" . number_format($todayAmount, 0) . ")", + ]; + + if ($todayIncomplete > 0) { + $parts[] = "{$todayIncomplete} incomplete"; + } + + return implode(' · ', $parts); + } +}