Settings + Admin redesign + Community Leader role
## 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 <details>) 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
This commit is contained in:
152
pledge-now-pay-later/src/app/api/team/route.ts
Normal file
152
pledge-now-pay-later/src/app/api/team/route.ts
Normal file
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,23 +2,42 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from "react"
|
import { useState, useEffect } from "react"
|
||||||
import { useSession } from "next-auth/react"
|
import { useSession } from "next-auth/react"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { formatPence } from "@/lib/utils"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Shield, Loader2, AlertTriangle, Building2, Users, Banknote } from "lucide-react"
|
||||||
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table"
|
|
||||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
|
/**
|
||||||
import {
|
* /dashboard/admin — Platform-wide super admin view
|
||||||
Shield, Building2, Users, Banknote, Calendar, TrendingUp, Loader2, AlertTriangle
|
*
|
||||||
} from "lucide-react"
|
* 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 {
|
interface AdminData {
|
||||||
platform: { orgs: number; users: number; events: number; pledges: number; totalPledgedPence: number; totalCollectedPence: number; collectionRate: number }
|
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 }>
|
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 }>
|
users: Array<{ id: string; email: string; name: string | null; role: string; orgName: string; createdAt: string }>
|
||||||
byStatus: Record<string, { count: number; amount: number }>
|
byStatus: Record<string, { count: number; amount: number }>
|
||||||
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<string, { label: string; color: string; bg: string }> = {
|
||||||
|
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<string, string> = {
|
||||||
|
super_admin: "Super Admin",
|
||||||
|
org_admin: "Admin",
|
||||||
|
community_leader: "Community Leader",
|
||||||
|
staff: "Staff",
|
||||||
|
volunteer: "Volunteer",
|
||||||
|
}
|
||||||
|
|
||||||
export default function AdminPage() {
|
export default function AdminPage() {
|
||||||
const { data: session } = useSession()
|
const { data: session } = useSession()
|
||||||
@@ -27,7 +46,7 @@ export default function AdminPage() {
|
|||||||
const [data, setData] = useState<AdminData | null>(null)
|
const [data, setData] = useState<AdminData | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState("")
|
const [error, setError] = useState("")
|
||||||
const [tab, setTab] = useState("orgs")
|
const [tab, setTab] = useState<"orgs" | "users" | "pledges">("orgs")
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch("/api/admin")
|
fetch("/api/admin")
|
||||||
@@ -40,180 +59,177 @@ export default function AdminPage() {
|
|||||||
if (user?.role !== "super_admin") {
|
if (user?.role !== "super_admin") {
|
||||||
return (
|
return (
|
||||||
<div className="text-center py-20 space-y-3">
|
<div className="text-center py-20 space-y-3">
|
||||||
<Shield className="h-10 w-10 text-danger-red mx-auto" />
|
<Shield className="h-10 w-10 text-[#DC2626] mx-auto" />
|
||||||
<h2 className="text-xl font-bold">Access Denied</h2>
|
<h2 className="text-xl font-black text-[#111827]">Access Denied</h2>
|
||||||
<p className="text-sm text-muted-foreground">Super admin access required.</p>
|
<p className="text-sm text-gray-500">Super admin access required.</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) return <div className="flex items-center justify-center py-20"><Loader2 className="h-8 w-8 text-trust-blue animate-spin" /></div>
|
if (loading) return <div className="flex items-center justify-center py-20"><Loader2 className="h-6 w-6 text-[#1E40AF] animate-spin" /></div>
|
||||||
if (error || !data) return <div className="text-center py-20"><AlertTriangle className="h-8 w-8 text-danger-red mx-auto mb-2" /><p className="text-muted-foreground">{error}</p></div>
|
if (error || !data) return <div className="text-center py-20"><AlertTriangle className="h-8 w-8 text-[#DC2626] mx-auto mb-2" /><p className="text-sm text-gray-500">{error}</p></div>
|
||||||
|
|
||||||
const p = data.platform
|
const p = data.platform
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Shield className="h-6 w-6 text-trust-blue" />
|
<div className="w-10 h-10 bg-[#1E40AF]/10 flex items-center justify-center">
|
||||||
|
<Shield className="h-5 w-5 text-[#1E40AF]" />
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-black text-gray-900">Super Admin</h1>
|
<h1 className="text-3xl font-black text-[#111827] tracking-tight">Platform Admin</h1>
|
||||||
<p className="text-xs text-muted-foreground">Platform-wide view · {user?.email}</p>
|
<p className="text-xs text-gray-500">{user?.email}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Platform stats */}
|
{/* Platform stats */}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-3">
|
<div className="bg-[#111827] p-6">
|
||||||
{[
|
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest mb-4">Platform overview</p>
|
||||||
{ label: "Orgs", value: p.orgs, icon: Building2, color: "text-trust-blue" },
|
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-px bg-gray-700">
|
||||||
{ label: "Users", value: p.users, icon: Users, color: "text-warm-amber" },
|
{[
|
||||||
{ label: "Events", value: p.events, icon: Calendar, color: "text-purple-500" },
|
{ value: String(p.orgs), label: "Charities" },
|
||||||
{ label: "Pledges", value: p.pledges, icon: TrendingUp, color: "text-success-green" },
|
{ value: String(p.users), label: "Users" },
|
||||||
{ label: "Pledged", value: fmt(p.totalPledgedPence), icon: Banknote, color: "text-trust-blue" },
|
{ value: String(p.events), label: "Appeals" },
|
||||||
{ label: "Collected", value: fmt(p.totalCollectedPence), icon: Banknote, color: "text-success-green" },
|
{ value: String(p.pledges), label: "Pledges" },
|
||||||
{ label: "Rate", value: `${p.collectionRate}%`, icon: TrendingUp, color: p.collectionRate > 50 ? "text-success-green" : "text-warm-amber" },
|
{ value: formatPence(p.totalPledgedPence), label: "Promised" },
|
||||||
].map(s => (
|
{ value: formatPence(p.totalCollectedPence), label: "Received", color: "text-[#4ADE80]" },
|
||||||
<Card key={s.label}>
|
{ value: `${p.collectionRate}%`, label: "Rate", color: p.collectionRate >= 50 ? "text-[#4ADE80]" : "text-[#FBBF24]" },
|
||||||
<CardContent className="pt-4 pb-3">
|
].map(s => (
|
||||||
<div className="flex items-center gap-1.5">
|
<div key={s.label} className="bg-[#111827] p-3 text-center">
|
||||||
<s.icon className={`h-3.5 w-3.5 ${s.color}`} />
|
<p className={`text-lg font-black ${s.color || "text-white"}`}>{s.value}</p>
|
||||||
<span className="text-[10px] text-muted-foreground">{s.label}</span>
|
<p className="text-[9px] text-gray-500 mt-0.5">{s.label}</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-lg font-black mt-0.5">{s.value}</p>
|
))}
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pipeline */}
|
{/* Pipeline */}
|
||||||
<Card>
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-px bg-gray-200">
|
||||||
<CardHeader className="pb-2">
|
{Object.entries(data.byStatus).map(([status, { count, amount }]) => {
|
||||||
<CardTitle className="text-sm">Pipeline</CardTitle>
|
const sl = STATUS[status] || STATUS.new
|
||||||
</CardHeader>
|
return (
|
||||||
<CardContent>
|
<div key={status} className="bg-white p-4 text-center">
|
||||||
<div className="flex gap-3 overflow-x-auto">
|
<span className={`text-[10px] font-bold px-1.5 py-0.5 ${sl.bg} ${sl.color}`}>{sl.label}</span>
|
||||||
{Object.entries(data.byStatus).map(([status, { count, amount }]) => (
|
<p className="text-xl font-black text-[#111827] mt-2">{count}</p>
|
||||||
<div key={status} className="flex-shrink-0 rounded-lg bg-muted/50 px-4 py-2 text-center min-w-[100px]">
|
<p className="text-[10px] text-gray-500">{formatPence(amount)}</p>
|
||||||
<Badge variant={status === "paid" ? "success" : status === "overdue" ? "destructive" : "secondary"} className="text-[10px]">{status}</Badge>
|
</div>
|
||||||
<p className="text-lg font-bold mt-1">{count}</p>
|
)
|
||||||
<p className="text-[10px] text-muted-foreground">{fmt(amount)}</p>
|
})}
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
|
{/* Tab switcher */}
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
{[
|
||||||
|
{ 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 => (
|
||||||
|
<button
|
||||||
|
key={t.key}
|
||||||
|
onClick={() => setTab(t.key)}
|
||||||
|
className={`px-4 py-2 text-xs font-bold flex items-center gap-1.5 transition-colors ${
|
||||||
|
tab === t.key ? "bg-[#111827] text-white" : "border border-gray-200 text-gray-600 hover:bg-gray-50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<t.icon className="h-3.5 w-3.5" /> {t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Orgs tab ── */}
|
||||||
|
{tab === "orgs" && (
|
||||||
|
<div className="bg-white border border-gray-200">
|
||||||
|
<div className="hidden md:grid grid-cols-12 gap-2 px-5 py-2.5 border-b border-gray-100 text-[10px] font-bold text-gray-400 uppercase tracking-wide">
|
||||||
|
<div className="col-span-4">Charity</div>
|
||||||
|
<div className="col-span-1">Bank</div>
|
||||||
|
<div className="col-span-1">Users</div>
|
||||||
|
<div className="col-span-2">Appeals</div>
|
||||||
|
<div className="col-span-2">Pledges</div>
|
||||||
|
<div className="col-span-2">Created</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
{data.orgs.map(o => (
|
||||||
</Card>
|
<div key={o.id} className="grid grid-cols-12 gap-2 px-5 py-3 border-b border-gray-50 items-center hover:bg-gray-50/50">
|
||||||
|
<div className="col-span-4">
|
||||||
|
<p className="text-sm font-medium text-[#111827]">{o.name}</p>
|
||||||
|
<p className="text-[10px] text-gray-400 font-mono">{o.slug}</p>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-1">
|
||||||
|
<span className={`text-[9px] font-bold px-1.5 py-0.5 ${o.hasBank ? "bg-[#16A34A]/10 text-[#16A34A]" : "bg-[#F59E0B]/10 text-[#F59E0B]"}`}>
|
||||||
|
{o.hasBank ? "Set" : "Missing"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-1 text-sm text-[#111827]">{o.users}</div>
|
||||||
|
<div className="col-span-2 text-sm text-[#111827]">{o.events}</div>
|
||||||
|
<div className="col-span-2 text-sm font-bold text-[#111827]">{o.pledges}</div>
|
||||||
|
<div className="col-span-2 text-xs text-gray-500">{new Date(o.createdAt).toLocaleDateString("en-GB", { day: "numeric", month: "short" })}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{data.orgs.length === 0 && <div className="px-5 py-8 text-center text-sm text-gray-400">No charities yet</div>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<Tabs value={tab} onValueChange={setTab}>
|
{/* ── Users tab ── */}
|
||||||
<TabsList>
|
{tab === "users" && (
|
||||||
<TabsTrigger value="orgs">Organisations ({data.orgs.length})</TabsTrigger>
|
<div className="bg-white border border-gray-200">
|
||||||
<TabsTrigger value="users">Users ({data.users.length})</TabsTrigger>
|
<div className="hidden md:grid grid-cols-12 gap-2 px-5 py-2.5 border-b border-gray-100 text-[10px] font-bold text-gray-400 uppercase tracking-wide">
|
||||||
<TabsTrigger value="pledges">Recent Pledges</TabsTrigger>
|
<div className="col-span-4">Email</div>
|
||||||
</TabsList>
|
<div className="col-span-2">Name</div>
|
||||||
|
<div className="col-span-2">Role</div>
|
||||||
|
<div className="col-span-2">Charity</div>
|
||||||
|
<div className="col-span-2">Joined</div>
|
||||||
|
</div>
|
||||||
|
{data.users.map(u => (
|
||||||
|
<div key={u.id} className="grid grid-cols-12 gap-2 px-5 py-3 border-b border-gray-50 items-center hover:bg-gray-50/50">
|
||||||
|
<div className="col-span-4 text-xs font-mono text-gray-600 truncate">{u.email}</div>
|
||||||
|
<div className="col-span-2 text-sm text-[#111827]">{u.name || "—"}</div>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<span className={`text-[9px] font-bold px-1.5 py-0.5 ${
|
||||||
|
u.role === "super_admin" ? "bg-[#1E40AF]/10 text-[#1E40AF]" :
|
||||||
|
u.role === "org_admin" ? "bg-[#16A34A]/10 text-[#16A34A]" :
|
||||||
|
u.role === "community_leader" ? "bg-[#F59E0B]/10 text-[#F59E0B]" :
|
||||||
|
"bg-gray-100 text-gray-500"
|
||||||
|
}`}>
|
||||||
|
{ROLE_LABELS[u.role] || u.role}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 text-xs text-gray-600 truncate">{u.orgName}</div>
|
||||||
|
<div className="col-span-2 text-xs text-gray-500">{new Date(u.createdAt).toLocaleDateString("en-GB", { day: "numeric", month: "short" })}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<TabsContent value="orgs">
|
{/* ── Recent pledges tab ── */}
|
||||||
<Card>
|
{tab === "pledges" && (
|
||||||
<CardContent className="p-0">
|
<div className="bg-white border border-gray-200">
|
||||||
<Table>
|
<div className="hidden md:grid grid-cols-12 gap-2 px-5 py-2.5 border-b border-gray-100 text-[10px] font-bold text-gray-400 uppercase tracking-wide">
|
||||||
<TableHeader>
|
<div className="col-span-2">Reference</div>
|
||||||
<TableRow>
|
<div className="col-span-2">Donor</div>
|
||||||
<TableHead>Name</TableHead>
|
<div className="col-span-1">Amount</div>
|
||||||
<TableHead>Bank</TableHead>
|
<div className="col-span-2">Status</div>
|
||||||
<TableHead>Users</TableHead>
|
<div className="col-span-2">Appeal</div>
|
||||||
<TableHead>Events</TableHead>
|
<div className="col-span-1">Charity</div>
|
||||||
<TableHead>Pledges</TableHead>
|
<div className="col-span-2">Date</div>
|
||||||
<TableHead>Created</TableHead>
|
</div>
|
||||||
</TableRow>
|
{data.recentPledges.map(pledge => {
|
||||||
</TableHeader>
|
const sl = STATUS[pledge.status] || STATUS.new
|
||||||
<TableBody>
|
return (
|
||||||
{data.orgs.map(o => (
|
<div key={pledge.id} className="grid grid-cols-12 gap-2 px-5 py-3 border-b border-gray-50 items-center hover:bg-gray-50/50">
|
||||||
<TableRow key={o.id}>
|
<div className="col-span-2 text-xs font-mono text-gray-500">{pledge.reference}</div>
|
||||||
<TableCell>
|
<div className="col-span-2 text-sm text-[#111827]">{pledge.donorName || "Anon"}</div>
|
||||||
<p className="font-medium text-sm">{o.name}</p>
|
<div className="col-span-1 text-sm font-black text-[#111827]">{formatPence(pledge.amountPence)}</div>
|
||||||
<p className="text-[10px] text-muted-foreground font-mono">{o.slug}</p>
|
<div className="col-span-2"><span className={`text-[9px] font-bold px-1.5 py-0.5 ${sl.bg} ${sl.color}`}>{sl.label}</span></div>
|
||||||
</TableCell>
|
<div className="col-span-2 text-xs text-gray-600 truncate">{pledge.eventName}</div>
|
||||||
<TableCell>
|
<div className="col-span-1 text-xs text-gray-500 truncate">{pledge.orgName}</div>
|
||||||
{o.hasBank ? <Badge variant="success" className="text-[10px]">✓ Set</Badge> : <Badge variant="warning" className="text-[10px]">Missing</Badge>}
|
<div className="col-span-2 text-xs text-gray-500">{new Date(pledge.createdAt).toLocaleDateString("en-GB", { day: "numeric", month: "short" })}</div>
|
||||||
</TableCell>
|
</div>
|
||||||
<TableCell className="font-medium">{o.users}</TableCell>
|
)
|
||||||
<TableCell className="font-medium">{o.events}</TableCell>
|
})}
|
||||||
<TableCell className="font-medium">{o.pledges}</TableCell>
|
</div>
|
||||||
<TableCell className="text-xs text-muted-foreground">{new Date(o.createdAt).toLocaleDateString("en-GB", { day: "numeric", month: "short" })}</TableCell>
|
)}
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="users">
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-0">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>Email</TableHead>
|
|
||||||
<TableHead>Name</TableHead>
|
|
||||||
<TableHead>Role</TableHead>
|
|
||||||
<TableHead>Organisation</TableHead>
|
|
||||||
<TableHead>Joined</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{data.users.map(u => (
|
|
||||||
<TableRow key={u.id}>
|
|
||||||
<TableCell className="font-mono text-xs">{u.email}</TableCell>
|
|
||||||
<TableCell className="text-sm">{u.name || "—"}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant={u.role === "super_admin" ? "default" : u.role === "org_admin" ? "success" : "secondary"} className="text-[10px]">
|
|
||||||
{u.role === "super_admin" ? "🛡️ Super" : u.role === "org_admin" ? "Admin" : u.role}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-sm">{u.orgName}</TableCell>
|
|
||||||
<TableCell className="text-xs text-muted-foreground">{new Date(u.createdAt).toLocaleDateString("en-GB", { day: "numeric", month: "short" })}</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="pledges">
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-0">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>Reference</TableHead>
|
|
||||||
<TableHead>Donor</TableHead>
|
|
||||||
<TableHead>Amount</TableHead>
|
|
||||||
<TableHead>Status</TableHead>
|
|
||||||
<TableHead>Event</TableHead>
|
|
||||||
<TableHead>Org</TableHead>
|
|
||||||
<TableHead>Date</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{data.recentPledges.map(p => (
|
|
||||||
<TableRow key={p.id}>
|
|
||||||
<TableCell className="font-mono text-xs">{p.reference}</TableCell>
|
|
||||||
<TableCell className="text-sm">{p.donorName || "Anon"}</TableCell>
|
|
||||||
<TableCell className="font-bold text-sm">{fmt(p.amountPence)}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant={p.status === "paid" ? "success" : p.status === "overdue" ? "destructive" : "secondary"} className="text-[10px]">{p.status}</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-xs truncate max-w-[120px]">{p.eventName}</TableCell>
|
|
||||||
<TableCell className="text-xs text-muted-foreground">{p.orgName}</TableCell>
|
|
||||||
<TableCell className="text-xs text-muted-foreground">{new Date(p.createdAt).toLocaleDateString("en-GB", { day: "numeric", month: "short" })}</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
350
pledge-now-pay-later/src/app/dashboard/community/page.tsx
Normal file
350
pledge-now-pay-later/src/app/dashboard/community/page.tsx
Normal file
@@ -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<string, { label: string; color: string; bg: string }> = {
|
||||||
|
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<EventSummary[]>([])
|
||||||
|
const [allSources, setAllSources] = useState<SourceInfo[]>([])
|
||||||
|
const [mySources, setMySources] = useState<SourceInfo[]>([])
|
||||||
|
const [myPledges, setMyPledges] = useState<PledgeInfo[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [copiedCode, setCopiedCode] = useState<string | null>(null)
|
||||||
|
const [showQr, setShowQr] = useState<string | null>(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 <div className="flex items-center justify-center py-20"><Loader2 className="h-6 w-6 text-[#1E40AF] animate-spin" /></div>
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
|
||||||
|
{/* ── Header: "How are WE doing?" ── */}
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
||||||
|
{activeEvent?.name || "Your community"}
|
||||||
|
</p>
|
||||||
|
<h1 className="text-3xl font-black text-[#111827] tracking-tight">
|
||||||
|
Welcome back, {userName.split(" ")[0] || "Leader"}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── My community's stats vs the whole appeal ── */}
|
||||||
|
<div className="bg-[#111827] p-6">
|
||||||
|
<div className="grid grid-cols-2 gap-px bg-gray-700">
|
||||||
|
<div className="bg-[#111827] p-4">
|
||||||
|
<p className="text-[10px] text-gray-500">Your community</p>
|
||||||
|
<p className="text-3xl font-black text-white tracking-tight">{formatPence(myTotal)}</p>
|
||||||
|
<p className="text-xs text-gray-400">{myPledgeCount} pledges from {mySources.length} links</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-[#111827] p-4">
|
||||||
|
<p className="text-[10px] text-gray-500">Whole appeal</p>
|
||||||
|
<p className="text-3xl font-black text-gray-400 tracking-tight">{formatPence(appealTotal)}</p>
|
||||||
|
<p className="text-xs text-gray-400">{activeEvent?.pledgeCount || 0} total pledges</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{appealTotal > 0 && (
|
||||||
|
<div className="mt-4 pt-3 border-t border-gray-800">
|
||||||
|
<div className="flex justify-between text-xs text-gray-500 mb-2">
|
||||||
|
<span>Your contribution</span>
|
||||||
|
<span className="font-bold text-white">{myPct}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-gray-800 overflow-hidden">
|
||||||
|
<div className="h-full bg-[#4ADE80] transition-all duration-700" style={{ width: `${myPct}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Your links + share ── */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-sm font-bold text-[#111827]">Your links ({mySources.length})</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreate(true)}
|
||||||
|
className="bg-[#111827] px-3 py-1.5 text-xs font-bold text-white hover:bg-gray-800 transition-colors flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5" /> New link
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showCreate && (
|
||||||
|
<div className="bg-white border-2 border-[#1E40AF] p-4 space-y-3">
|
||||||
|
<p className="text-sm font-bold text-[#111827]">Create a link for your community</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
value={newLinkName}
|
||||||
|
onChange={e => 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"
|
||||||
|
/>
|
||||||
|
<button onClick={createLink} disabled={!newLinkName.trim() || creating} className="bg-[#111827] px-5 h-11 text-sm font-bold text-white hover:bg-gray-800 disabled:opacity-40">
|
||||||
|
{creating ? <Loader2 className="h-4 w-4 animate-spin" /> : "Create"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => { setShowCreate(false); setNewLinkName("") }} className="text-xs text-gray-400 hover:text-gray-600">Cancel</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mySources.length === 0 ? (
|
||||||
|
<div className="border-2 border-dashed border-gray-200 p-8 text-center">
|
||||||
|
<Link2 className="h-8 w-8 text-gray-300 mx-auto mb-3" />
|
||||||
|
<p className="text-sm font-bold text-[#111827]">No links yet</p>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">Create a link to start collecting pledges for your community</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{mySources.map(src => {
|
||||||
|
const url = `${baseUrl}/p/${src.code}`
|
||||||
|
const isCopied = copiedCode === src.code
|
||||||
|
const isQrOpen = showQr === src.code
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={src.id} className="bg-white border border-gray-200">
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="flex items-start justify-between gap-3 mb-3">
|
||||||
|
<p className="text-sm font-bold text-[#111827]">{src.label}</p>
|
||||||
|
<div className="flex gap-px bg-gray-200 shrink-0">
|
||||||
|
<div className="bg-white px-2.5 py-1.5 text-center">
|
||||||
|
<p className="text-sm font-black text-[#111827]">{src.pledgeCount}</p>
|
||||||
|
<p className="text-[8px] text-gray-500">pledges</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white px-2.5 py-1.5 text-center">
|
||||||
|
<p className="text-sm font-black text-[#16A34A]">{formatPence(src.totalPledged)}</p>
|
||||||
|
<p className="text-[8px] text-gray-500">raised</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-[#F9FAFB] px-3 py-2 mb-3">
|
||||||
|
<p className="text-xs font-mono text-gray-500 truncate">{url}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-4 gap-1.5">
|
||||||
|
<button onClick={() => copyLink(src.code)} className={`py-2.5 text-xs font-bold transition-colors flex items-center justify-center gap-1.5 ${isCopied ? "bg-[#16A34A] text-white" : "bg-[#111827] text-white hover:bg-gray-800"}`}>
|
||||||
|
{isCopied ? <><Check className="h-3.5 w-3.5" /> Copied</> : <><Copy className="h-3.5 w-3.5" /> Copy</>}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => shareWhatsApp(src.code, src.label)} className="bg-[#25D366] text-white py-2.5 text-xs font-bold hover:bg-[#25D366]/90 transition-colors flex items-center justify-center gap-1.5">
|
||||||
|
<MessageCircle className="h-3.5 w-3.5" /> WhatsApp
|
||||||
|
</button>
|
||||||
|
<button onClick={() => window.open(`mailto:?subject=${encodeURIComponent(`Pledge: ${src.label}`)}&body=${encodeURIComponent(`Pledge here:\n${url}`)}`)} className="border border-gray-200 py-2.5 text-xs font-bold hover:bg-gray-50 transition-colors flex items-center justify-center gap-1.5">
|
||||||
|
<Mail className="h-3.5 w-3.5" /> Email
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setShowQr(isQrOpen ? null : src.code)} className="border border-gray-200 py-2.5 text-xs font-bold hover:bg-gray-50 transition-colors flex items-center justify-center gap-1.5">
|
||||||
|
<QrCodeIcon className="h-3.5 w-3.5" /> QR
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isQrOpen && (
|
||||||
|
<div className="mt-3 pt-3 border-t border-gray-100 flex flex-col items-center gap-2">
|
||||||
|
<div className="bg-white p-2 border border-gray-100"><QRCodeCanvas url={url} size={160} /></div>
|
||||||
|
<a href={`/api/events/${events[0]?.id}/qr/${src.id}/download?code=${src.code}`} download className="text-[10px] font-semibold text-[#1E40AF] hover:underline flex items-center gap-1">
|
||||||
|
<Download className="h-3 w-3" /> Download QR image
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Leaderboard — how do we compare? ── */}
|
||||||
|
{leaderboard.filter(s => s.pledgeCount > 0).length >= 2 && (
|
||||||
|
<div className="bg-white border border-gray-200">
|
||||||
|
<div className="border-b border-gray-100 px-5 py-3">
|
||||||
|
<h3 className="text-sm font-bold text-[#111827] flex items-center gap-1.5">
|
||||||
|
<Trophy className="h-4 w-4 text-[#F59E0B]" /> How communities compare
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-gray-50">
|
||||||
|
{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 (
|
||||||
|
<div key={src.id} className={`px-5 py-3 flex items-center gap-3 ${isMine ? "bg-[#1E40AF]/5" : ""}`}>
|
||||||
|
<div className={`w-6 h-6 flex items-center justify-center text-[10px] font-black text-white ${medals[i] || "bg-gray-200 text-gray-500"}`}>
|
||||||
|
{i + 1}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="text-sm font-medium text-[#111827] truncate">{src.volunteerName || src.label}</p>
|
||||||
|
{isMine && <span className="text-[9px] font-bold text-[#1E40AF] bg-[#1E40AF]/10 px-1 py-0.5">You</span>}
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-gray-500">{src.pledgeCount} pledges</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-black text-[#111827]">{formatPence(src.totalPledged)}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Recent pledges (read-only, no actions) ── */}
|
||||||
|
{myPledges.length > 0 && (
|
||||||
|
<div className="bg-white border border-gray-200">
|
||||||
|
<div className="border-b border-gray-100 px-5 py-3">
|
||||||
|
<h3 className="text-sm font-bold text-[#111827]">Recent pledges</h3>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-gray-50">
|
||||||
|
{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 (
|
||||||
|
<div key={p.id} className="px-5 py-3 flex items-center gap-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-[#111827] truncate">{p.donorName || "Anonymous"}</p>
|
||||||
|
<p className="text-[10px] text-gray-500">{when}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right shrink-0">
|
||||||
|
<p className="text-sm font-black text-[#111827]">{formatPence(p.amountPence)}</p>
|
||||||
|
<span className={`text-[9px] font-bold px-1.5 py-0.5 ${sl.bg} ${sl.color}`}>{sl.label}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,18 +4,16 @@ import Link from "next/link"
|
|||||||
import { usePathname } from "next/navigation"
|
import { usePathname } from "next/navigation"
|
||||||
import { useSession, signOut } from "next-auth/react"
|
import { useSession, signOut } from "next-auth/react"
|
||||||
import { useState, useEffect } from "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"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Navigation: goal-oriented, not feature-oriented
|
* Navigation: goal-oriented, not feature-oriented
|
||||||
* "Home" — where am I at?
|
* Different nav for different roles:
|
||||||
* "Collect" — I want people to pledge
|
* - Admin/Staff: Full nav (Home, Collect, Money, Reports, Settings)
|
||||||
* "Money" — where's the money?
|
* - Community Leader: Scoped nav (My Community, Collect, Reports)
|
||||||
* "Reports" — my treasurer needs numbers
|
|
||||||
* "Settings" — connect WhatsApp, bank details
|
|
||||||
*/
|
*/
|
||||||
const navItems = [
|
const adminNavItems = [
|
||||||
{ href: "/dashboard", label: "Home", icon: Home },
|
{ href: "/dashboard", label: "Home", icon: Home },
|
||||||
{ href: "/dashboard/collect", label: "Collect", icon: Megaphone },
|
{ href: "/dashboard/collect", label: "Collect", icon: Megaphone },
|
||||||
{ href: "/dashboard/money", label: "Money", icon: Banknote },
|
{ href: "/dashboard/money", label: "Money", icon: Banknote },
|
||||||
@@ -23,7 +21,13 @@ const navItems = [
|
|||||||
{ href: "/dashboard/settings", label: "Settings", icon: Settings },
|
{ 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 }) {
|
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
@@ -31,9 +35,13 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const user = session?.user as 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
|
// Map old routes to new ones for active state
|
||||||
const isActive = (href: string) => {
|
const isActive = (href: string) => {
|
||||||
if (href === "/dashboard") return pathname === "/dashboard"
|
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/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/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")
|
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
|
|||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<Link href="/dashboard/collect">
|
{!isCommunityLeader && (
|
||||||
<button className="hidden md:inline-flex items-center gap-1.5 bg-[#111827] px-3.5 py-1.5 text-xs font-bold text-white hover:bg-gray-800 transition-colors">
|
<Link href="/dashboard/collect">
|
||||||
<Plus className="h-3 w-3" /> New Appeal
|
<button className="hidden md:inline-flex items-center gap-1.5 bg-[#111827] px-3.5 py-1.5 text-xs font-bold text-white hover:bg-gray-800 transition-colors">
|
||||||
</button>
|
<Plus className="h-3 w-3" /> New Appeal
|
||||||
</Link>
|
</button>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
{session && (
|
{session && (
|
||||||
<button
|
<button
|
||||||
onClick={() => signOut({ callbackUrl: "/login" })}
|
onClick={() => signOut({ callbackUrl: "/login" })}
|
||||||
@@ -97,16 +107,16 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
|||||||
<>
|
<>
|
||||||
<div className="my-3 border-t border-gray-100" />
|
<div className="my-3 border-t border-gray-100" />
|
||||||
<Link
|
<Link
|
||||||
href={adminNav.href}
|
href={superAdminNav.href}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2.5 px-3 py-2.5 text-[13px] font-medium transition-colors",
|
"flex items-center gap-2.5 px-3 py-2.5 text-[13px] font-medium transition-colors",
|
||||||
pathname === adminNav.href
|
pathname === superAdminNav.href
|
||||||
? "bg-[#1E40AF]/5 text-[#1E40AF] border-l-2 border-[#1E40AF] -ml-[2px] pl-[14px]"
|
? "bg-[#1E40AF]/5 text-[#1E40AF] border-l-2 border-[#1E40AF] -ml-[2px] pl-[14px]"
|
||||||
: "text-gray-500 hover:bg-gray-50 hover:text-[#111827]"
|
: "text-gray-500 hover:bg-gray-50 hover:text-[#111827]"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<adminNav.icon className="h-4 w-4" />
|
<superAdminNav.icon className="h-4 w-4" />
|
||||||
{adminNav.label}
|
{superAdminNav.label}
|
||||||
</Link>
|
</Link>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,31 +1,76 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react"
|
import { useState, useEffect, useCallback } from "react"
|
||||||
|
import { useSession } from "next-auth/react"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import {
|
import {
|
||||||
Check, Loader2, AlertCircle,
|
Check, Loader2, AlertCircle, MessageCircle, Radio, RefreshCw,
|
||||||
MessageCircle, Radio, RefreshCw, Smartphone, Wifi, WifiOff, QrCode
|
Smartphone, Wifi, WifiOff, QrCode, UserPlus, Trash2, Copy,
|
||||||
|
Users, Crown, Eye
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /dashboard/settings — Aaisha's control panel
|
||||||
|
*
|
||||||
|
* Organised by what she's thinking, not by system concept:
|
||||||
|
* 1. WhatsApp — "I need to connect" (or see it's connected)
|
||||||
|
* 2. Team — "Who has access? I need to invite Imam Yusuf"
|
||||||
|
* 3. Bank — "Where donors send money"
|
||||||
|
* 4. Your charity — name, brand colour
|
||||||
|
* 5. Direct Debit — advanced, for later
|
||||||
|
*
|
||||||
|
* Team management is NEW — the missing feature.
|
||||||
|
* This is how community leaders get invited.
|
||||||
|
*/
|
||||||
|
|
||||||
interface OrgSettings {
|
interface OrgSettings {
|
||||||
name: string; bankName: string; bankSortCode: string; bankAccountNo: string
|
name: string; bankName: string; bankSortCode: string; bankAccountNo: string
|
||||||
bankAccountName: string; refPrefix: string; primaryColor: string
|
bankAccountName: string; refPrefix: string; primaryColor: string
|
||||||
gcAccessToken: string; gcEnvironment: string; orgType: string
|
gcAccessToken: string; gcEnvironment: string; orgType: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TeamMember {
|
||||||
|
id: string; email: string; name: string | null; role: string; createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROLE_LABELS: Record<string, { label: string; desc: string; icon: typeof Crown }> = {
|
||||||
|
org_admin: { label: "Admin", desc: "Full access to everything", icon: Crown },
|
||||||
|
community_leader: { label: "Community Leader", desc: "Can see their links, pledges, and share. Can't change settings.", icon: Users },
|
||||||
|
staff: { label: "Staff", desc: "Can view pledges and reports", icon: Eye },
|
||||||
|
volunteer: { label: "Volunteer", desc: "Read-only access", icon: Eye },
|
||||||
|
}
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
|
const { data: session } = useSession()
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const currentUser = session?.user as any
|
||||||
|
|
||||||
const [settings, setSettings] = useState<OrgSettings | null>(null)
|
const [settings, setSettings] = useState<OrgSettings | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [saving, setSaving] = useState<string | null>(null)
|
const [saving, setSaving] = useState<string | null>(null)
|
||||||
const [saved, setSaved] = useState<string | null>(null)
|
const [saved, setSaved] = useState<string | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Team
|
||||||
|
const [team, setTeam] = useState<TeamMember[]>([])
|
||||||
|
const [showInvite, setShowInvite] = useState(false)
|
||||||
|
const [inviteEmail, setInviteEmail] = useState("")
|
||||||
|
const [inviteName, setInviteName] = useState("")
|
||||||
|
const [inviteRole, setInviteRole] = useState("community_leader")
|
||||||
|
const [inviting, setInviting] = useState(false)
|
||||||
|
const [inviteResult, setInviteResult] = useState<{ email: string; tempPassword: string } | null>(null)
|
||||||
|
const [copiedCred, setCopiedCred] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch("/api/settings")
|
Promise.all([
|
||||||
.then(r => r.json())
|
fetch("/api/settings").then(r => r.json()),
|
||||||
.then(data => { if (data.name) setSettings(data) })
|
fetch("/api/team").then(r => r.json()).catch(() => ({ members: [] })),
|
||||||
.catch(() => setError("Failed to load settings"))
|
]).then(([settingsData, teamData]) => {
|
||||||
.finally(() => setLoading(false))
|
if (settingsData.name) setSettings(settingsData)
|
||||||
|
if (teamData.members) setTeam(teamData.members)
|
||||||
|
})
|
||||||
|
.catch(() => setError("Failed to load settings"))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const save = async (section: string, data: Record<string, string>) => {
|
const save = async (section: string, data: Record<string, string>) => {
|
||||||
@@ -38,10 +83,61 @@ export default function SettingsPage() {
|
|||||||
setSaving(null)
|
setSaving(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const inviteMember = async () => {
|
||||||
|
if (!inviteEmail.trim()) return
|
||||||
|
setInviting(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/team", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email: inviteEmail.trim(), name: inviteName.trim(), role: inviteRole }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (res.ok) {
|
||||||
|
setTeam(prev => [...prev, { id: data.id, email: data.email, name: data.name, role: data.role, createdAt: new Date().toISOString() }])
|
||||||
|
setInviteResult({ email: data.email, tempPassword: data.tempPassword })
|
||||||
|
setInviteEmail(""); setInviteName("")
|
||||||
|
} else {
|
||||||
|
setError(data.error || "Failed to invite")
|
||||||
|
}
|
||||||
|
} catch { setError("Failed to invite") }
|
||||||
|
setInviting(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const changeRole = async (userId: string, role: string) => {
|
||||||
|
try {
|
||||||
|
await fetch("/api/team", {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ userId, role }),
|
||||||
|
})
|
||||||
|
setTeam(prev => prev.map(m => m.id === userId ? { ...m, role } : m))
|
||||||
|
} catch { setError("Failed to update role") }
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeMember = async (userId: string) => {
|
||||||
|
if (!confirm("Remove this team member? They'll lose access immediately.")) return
|
||||||
|
try {
|
||||||
|
await fetch("/api/team", {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ userId }),
|
||||||
|
})
|
||||||
|
setTeam(prev => prev.filter(m => m.id !== userId))
|
||||||
|
} catch { setError("Failed to remove") }
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyCredentials = (email: string, password: string) => {
|
||||||
|
navigator.clipboard.writeText(`Email: ${email}\nPassword: ${password}\nLogin: ${window.location.origin}/login`)
|
||||||
|
setCopiedCred(true)
|
||||||
|
setTimeout(() => setCopiedCred(false), 2000)
|
||||||
|
}
|
||||||
|
|
||||||
if (loading) return <div className="flex items-center justify-center py-20"><Loader2 className="h-6 w-6 text-[#1E40AF] animate-spin" /></div>
|
if (loading) return <div className="flex items-center justify-center py-20"><Loader2 className="h-6 w-6 text-[#1E40AF] animate-spin" /></div>
|
||||||
if (!settings) return <div className="text-center py-20"><AlertCircle className="h-6 w-6 text-[#DC2626] mx-auto mb-2" /><p className="text-sm text-gray-500">Failed to load settings</p></div>
|
if (!settings) return <div className="text-center py-20"><AlertCircle className="h-6 w-6 text-[#DC2626] mx-auto mb-2" /><p className="text-sm text-gray-500">Failed to load settings</p></div>
|
||||||
|
|
||||||
const update = (key: keyof OrgSettings, value: string) => setSettings(s => s ? { ...s, [key]: value } : s)
|
const update = (key: keyof OrgSettings, value: string) => setSettings(s => s ? { ...s, [key]: value } : s)
|
||||||
|
const isAdmin = currentUser?.role === "org_admin" || currentUser?.role === "super_admin"
|
||||||
|
|
||||||
const SaveButton = ({ section, data }: { section: string; data: Record<string, string> }) => (
|
const SaveButton = ({ section, data }: { section: string; data: Record<string, string> }) => (
|
||||||
<button
|
<button
|
||||||
@@ -57,19 +153,152 @@ export default function SettingsPage() {
|
|||||||
<div className="space-y-8 max-w-2xl">
|
<div className="space-y-8 max-w-2xl">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-black text-[#111827] tracking-tight">Settings</h1>
|
<h1 className="text-3xl font-black text-[#111827] tracking-tight">Settings</h1>
|
||||||
<p className="text-sm text-gray-500 mt-0.5">Your charity details, bank account, and connections</p>
|
<p className="text-sm text-gray-500 mt-0.5">WhatsApp, team, bank account, and charity details</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <div className="border-l-2 border-[#DC2626] bg-[#DC2626]/5 p-3 text-sm text-[#DC2626]">{error}</div>}
|
{error && <div className="border-l-2 border-[#DC2626] bg-[#DC2626]/5 p-3 text-sm text-[#DC2626]">{error}</div>}
|
||||||
|
|
||||||
{/* WhatsApp — most important, always first */}
|
{/* ── 1. WhatsApp ── */}
|
||||||
<WhatsAppPanel />
|
<WhatsAppPanel />
|
||||||
|
|
||||||
{/* Bank account */}
|
{/* ── 2. Team management ── */}
|
||||||
|
{isAdmin && (
|
||||||
|
<div className="bg-white border border-gray-200">
|
||||||
|
<div className="p-6 pb-4">
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<h3 className="text-base font-bold text-[#111827]">Team</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => { setShowInvite(!showInvite); setInviteResult(null) }}
|
||||||
|
className="bg-[#111827] px-3 py-1.5 text-xs font-bold text-white hover:bg-gray-800 transition-colors flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<UserPlus className="h-3.5 w-3.5" /> Invite
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500">People who can access your dashboard. Invite community leaders to track their pledges.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Invite form */}
|
||||||
|
{showInvite && !inviteResult && (
|
||||||
|
<div className="mx-6 mb-4 border-2 border-[#1E40AF] p-4 space-y-3">
|
||||||
|
<p className="text-sm font-bold text-[#111827]">Invite a team member</p>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] font-bold text-gray-500 block mb-1">Email</label>
|
||||||
|
<input value={inviteEmail} onChange={e => 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" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] font-bold text-gray-500 block mb-1">Name <span className="font-normal text-gray-400">(optional)</span></label>
|
||||||
|
<input value={inviteName} onChange={e => 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" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] font-bold text-gray-500 block mb-2">Role</label>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{Object.entries(ROLE_LABELS).filter(([k]) => k !== "org_admin" || currentUser?.role === "super_admin").map(([key, r]) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
onClick={() => setInviteRole(key)}
|
||||||
|
className={`border-2 p-3 text-left transition-all ${inviteRole === key ? "border-[#1E40AF] bg-[#1E40AF]/5" : "border-gray-200"}`}
|
||||||
|
>
|
||||||
|
<p className="text-xs font-bold text-[#111827]">{r.label}</p>
|
||||||
|
<p className="text-[10px] text-gray-500 mt-0.5">{r.desc}</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={() => setShowInvite(false)} className="flex-1 border border-gray-200 py-2 text-xs font-bold text-[#111827] hover:bg-gray-50">Cancel</button>
|
||||||
|
<button onClick={inviteMember} disabled={!inviteEmail.trim() || inviting} className="flex-1 bg-[#111827] py-2 text-xs font-bold text-white hover:bg-gray-800 disabled:opacity-40 flex items-center justify-center gap-1.5">
|
||||||
|
{inviting ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : "Send invite"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Invite result — show credentials once */}
|
||||||
|
{inviteResult && (
|
||||||
|
<div className="mx-6 mb-4 bg-[#16A34A]/5 border border-[#16A34A]/20 p-4 space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Check className="h-4 w-4 text-[#16A34A]" />
|
||||||
|
<p className="text-sm font-bold text-[#111827]">Invited!</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-600">Share these login details with them. The password is shown only once.</p>
|
||||||
|
<div className="bg-white border border-gray-200 p-3 font-mono text-xs space-y-1">
|
||||||
|
<p>Email: <strong>{inviteResult.email}</strong></p>
|
||||||
|
<p>Password: <strong>{inviteResult.tempPassword}</strong></p>
|
||||||
|
<p>Login: <strong>{typeof window !== "undefined" ? window.location.origin : ""}/login</strong></p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => copyCredentials(inviteResult.email, inviteResult.tempPassword)}
|
||||||
|
className="flex-1 bg-[#111827] py-2 text-xs font-bold text-white hover:bg-gray-800 flex items-center justify-center gap-1.5"
|
||||||
|
>
|
||||||
|
{copiedCred ? <><Check className="h-3 w-3" /> Copied</> : <><Copy className="h-3 w-3" /> Copy credentials</>}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const text = `Your login for ${settings?.name || "Pledge Now Pay Later"}:\n\nEmail: ${inviteResult.email}\nPassword: ${inviteResult.tempPassword}\nLogin: ${window.location.origin}/login`
|
||||||
|
window.open(`https://wa.me/?text=${encodeURIComponent(text)}`, "_blank")
|
||||||
|
}}
|
||||||
|
className="bg-[#25D366] py-2 px-4 text-xs font-bold text-white hover:bg-[#25D366]/90 flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<MessageCircle className="h-3 w-3" /> WhatsApp
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => { setInviteResult(null); setShowInvite(false) }} className="text-xs text-gray-400 hover:text-gray-600">Done</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Team list */}
|
||||||
|
<div className="divide-y divide-gray-50">
|
||||||
|
{team.map(m => {
|
||||||
|
const r = ROLE_LABELS[m.role] || ROLE_LABELS.staff
|
||||||
|
const isCurrentUser = m.id === currentUser?.id
|
||||||
|
const RoleIcon = r.icon
|
||||||
|
return (
|
||||||
|
<div key={m.id} className="px-6 py-3 flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 bg-[#1E40AF]/10 flex items-center justify-center shrink-0">
|
||||||
|
<RoleIcon className="h-4 w-4 text-[#1E40AF]" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="text-sm font-medium text-[#111827] truncate">{m.name || m.email}</p>
|
||||||
|
{isCurrentUser && <span className="text-[9px] font-bold text-gray-400">You</span>}
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-gray-500">{m.email}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
{isAdmin && !isCurrentUser ? (
|
||||||
|
<select
|
||||||
|
value={m.role}
|
||||||
|
onChange={e => changeRole(m.id, e.target.value)}
|
||||||
|
className="text-[10px] font-bold border border-gray-200 px-2 py-1 bg-white"
|
||||||
|
>
|
||||||
|
{Object.entries(ROLE_LABELS).map(([key, v]) => (
|
||||||
|
<option key={key} value={key}>{v.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<span className="text-[10px] font-bold px-2 py-0.5 bg-gray-100 text-gray-600">{r.label}</span>
|
||||||
|
)}
|
||||||
|
{isAdmin && !isCurrentUser && (
|
||||||
|
<button onClick={() => removeMember(m.id)} className="text-gray-300 hover:text-[#DC2626] p-1 transition-colors">
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── 3. Bank account ── */}
|
||||||
<div className="bg-white border border-gray-200 p-6 space-y-4">
|
<div className="bg-white border border-gray-200 p-6 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-base font-bold text-[#111827]">Bank account</h3>
|
<h3 className="text-base font-bold text-[#111827]">Bank account</h3>
|
||||||
<p className="text-xs text-gray-500 mt-0.5">These details are shown to donors so they can transfer money to you. Each pledge gets a unique reference code.</p>
|
<p className="text-xs text-gray-500 mt-0.5">Shown to donors so they know where to transfer. Each pledge gets a unique reference.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div><label className="text-[10px] font-bold text-gray-500 block mb-1">Bank name</label><Input value={settings.bankName} onChange={e => update("bankName", e.target.value)} placeholder="e.g. Barclays" /></div>
|
<div><label className="text-[10px] font-bold text-gray-500 block mb-1">Bank name</label><Input value={settings.bankName} onChange={e => update("bankName", e.target.value)} placeholder="e.g. Barclays" /></div>
|
||||||
@@ -80,41 +309,18 @@ export default function SettingsPage() {
|
|||||||
<div><label className="text-[10px] font-bold text-gray-500 block mb-1">Account number</label><Input value={settings.bankAccountNo} onChange={e => update("bankAccountNo", e.target.value)} placeholder="12345678" /></div>
|
<div><label className="text-[10px] font-bold text-gray-500 block mb-1">Account number</label><Input value={settings.bankAccountNo} onChange={e => update("bankAccountNo", e.target.value)} placeholder="12345678" /></div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-[10px] font-bold text-gray-500 block mb-1">Reference code prefix</label>
|
<label className="text-[10px] font-bold text-gray-500 block mb-1">Reference prefix</label>
|
||||||
<Input value={settings.refPrefix} onChange={e => update("refPrefix", e.target.value)} maxLength={4} className="w-24" />
|
<Input value={settings.refPrefix} onChange={e => update("refPrefix", e.target.value)} maxLength={4} className="w-24" />
|
||||||
<p className="text-[10px] text-gray-400 mt-1">Donors will see references like <strong>{settings.refPrefix}-XXXX-50</strong></p>
|
<p className="text-[10px] text-gray-400 mt-1">Donors see references like <strong>{settings.refPrefix}-XXXX-50</strong></p>
|
||||||
</div>
|
</div>
|
||||||
<SaveButton section="bank" data={{ bankName: settings.bankName, bankSortCode: settings.bankSortCode, bankAccountNo: settings.bankAccountNo, bankAccountName: settings.bankAccountName, refPrefix: settings.refPrefix }} />
|
<SaveButton section="bank" data={{ bankName: settings.bankName, bankSortCode: settings.bankSortCode, bankAccountNo: settings.bankAccountNo, bankAccountName: settings.bankAccountName, refPrefix: settings.refPrefix }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Direct Debit */}
|
{/* ── 4. Charity details ── */}
|
||||||
<div className="bg-white border border-gray-200 p-6 space-y-4">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-base font-bold text-[#111827]">Direct Debit</h3>
|
|
||||||
<p className="text-xs text-gray-500 mt-0.5">Accept Direct Debit payments via GoCardless. Donors set up a mandate and payments are collected automatically.</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-[10px] font-bold text-gray-500 block mb-1">GoCardless access token</label>
|
|
||||||
<Input type="password" value={settings.gcAccessToken} onChange={e => update("gcAccessToken", e.target.value)} placeholder="sandbox_xxxxx or live_xxxxx" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-[10px] font-bold text-gray-500 block mb-1">Mode</label>
|
|
||||||
<div className="flex gap-2 mt-1">
|
|
||||||
{["sandbox", "live"].map(env => (
|
|
||||||
<button key={env} onClick={() => update("gcEnvironment", env)} className={`px-3 py-1.5 text-xs font-bold border-2 transition-colors ${settings.gcEnvironment === env ? env === "live" ? "border-[#DC2626] bg-[#DC2626]/5 text-[#DC2626]" : "border-[#1E40AF] bg-[#1E40AF]/5 text-[#1E40AF]" : "border-gray-200 text-gray-400"}`}>
|
|
||||||
{env === "sandbox" ? "Test mode" : "Live mode"}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<SaveButton section="gc" data={{ gcAccessToken: settings.gcAccessToken, gcEnvironment: settings.gcEnvironment }} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Branding */}
|
|
||||||
<div className="bg-white border border-gray-200 p-6 space-y-4">
|
<div className="bg-white border border-gray-200 p-6 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-base font-bold text-[#111827]">Your charity</h3>
|
<h3 className="text-base font-bold text-[#111827]">Your charity</h3>
|
||||||
<p className="text-xs text-gray-500 mt-0.5">This name and colour appear on pledge pages and WhatsApp messages.</p>
|
<p className="text-xs text-gray-500 mt-0.5">Name and colour shown on pledge pages and WhatsApp messages.</p>
|
||||||
</div>
|
</div>
|
||||||
<div><label className="text-[10px] font-bold text-gray-500 block mb-1">Charity name</label><Input value={settings.name} onChange={e => update("name", e.target.value)} /></div>
|
<div><label className="text-[10px] font-bold text-gray-500 block mb-1">Charity name</label><Input value={settings.name} onChange={e => update("name", e.target.value)} /></div>
|
||||||
<div>
|
<div>
|
||||||
@@ -126,11 +332,36 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<SaveButton section="brand" data={{ name: settings.name, primaryColor: settings.primaryColor }} />
|
<SaveButton section="brand" data={{ name: settings.name, primaryColor: settings.primaryColor }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── 5. Direct Debit (collapsed) ── */}
|
||||||
|
<details className="bg-white border border-gray-200">
|
||||||
|
<summary className="p-6 text-base font-bold text-[#111827] cursor-pointer hover:bg-gray-50 transition-colors">
|
||||||
|
Direct Debit <span className="text-xs font-normal text-gray-400 ml-2">GoCardless integration</span>
|
||||||
|
</summary>
|
||||||
|
<div className="px-6 pb-6 space-y-4 border-t border-gray-100 pt-4">
|
||||||
|
<p className="text-xs text-gray-500">Accept Direct Debit payments via GoCardless. Donors set up a mandate and payments are collected automatically.</p>
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] font-bold text-gray-500 block mb-1">GoCardless access token</label>
|
||||||
|
<Input type="password" value={settings.gcAccessToken} onChange={e => update("gcAccessToken", e.target.value)} placeholder="sandbox_xxxxx or live_xxxxx" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] font-bold text-gray-500 block mb-1">Mode</label>
|
||||||
|
<div className="flex gap-2 mt-1">
|
||||||
|
{["sandbox", "live"].map(env => (
|
||||||
|
<button key={env} onClick={() => update("gcEnvironment", env)} className={`px-3 py-1.5 text-xs font-bold border-2 transition-colors ${settings.gcEnvironment === env ? env === "live" ? "border-[#DC2626] bg-[#DC2626]/5 text-[#DC2626]" : "border-[#1E40AF] bg-[#1E40AF]/5 text-[#1E40AF]" : "border-gray-200 text-gray-400"}`}>
|
||||||
|
{env === "sandbox" ? "Test mode" : "Live mode"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<SaveButton section="gc" data={{ gcAccessToken: settings.gcAccessToken, gcEnvironment: settings.gcEnvironment }} />
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── WhatsApp Connection Panel ───────────────────────────────
|
// ─── WhatsApp Connection Panel (unchanged) ───────────────────
|
||||||
|
|
||||||
function WhatsAppPanel() {
|
function WhatsAppPanel() {
|
||||||
const [status, setStatus] = useState<string>("loading")
|
const [status, setStatus] = useState<string>("loading")
|
||||||
@@ -167,10 +398,7 @@ function WhatsAppPanel() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="w-10 h-10 bg-[#25D366]/10 flex items-center justify-center"><Smartphone className="h-5 w-5 text-[#25D366]" /></div>
|
<div className="w-10 h-10 bg-[#25D366]/10 flex items-center justify-center"><Smartphone className="h-5 w-5 text-[#25D366]" /></div>
|
||||||
<div>
|
<div><p className="text-sm font-medium text-[#111827]">{pushName || "WhatsApp"}</p><p className="text-xs text-gray-500">+{phone}</p></div>
|
||||||
<p className="text-sm font-medium text-[#111827]">{pushName || "WhatsApp"}</p>
|
|
||||||
<p className="text-xs text-gray-500">+{phone}</p>
|
|
||||||
</div>
|
|
||||||
<Wifi className="h-5 w-5 text-[#25D366] ml-auto" />
|
<Wifi className="h-5 w-5 text-[#25D366] ml-auto" />
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 pt-3 border-t border-[#25D366]/10 grid grid-cols-3 gap-3">
|
<div className="mt-4 pt-3 border-t border-[#25D366]/10 grid grid-cols-3 gap-3">
|
||||||
@@ -178,12 +406,7 @@ function WhatsAppPanel() {
|
|||||||
{ label: "Receipts", desc: "Auto-sends when someone pledges" },
|
{ label: "Receipts", desc: "Auto-sends when someone pledges" },
|
||||||
{ label: "Reminders", desc: "4-step reminder sequence" },
|
{ label: "Reminders", desc: "4-step reminder sequence" },
|
||||||
{ label: "Chatbot", desc: "Donors reply PAID, HELP, etc." },
|
{ label: "Chatbot", desc: "Donors reply PAID, HELP, etc." },
|
||||||
].map(f => (
|
].map(f => (<div key={f.label} className="text-center"><p className="text-xs font-bold text-[#111827]">{f.label}</p><p className="text-[9px] text-gray-500 mt-0.5">{f.desc}</p></div>))}
|
||||||
<div key={f.label} className="text-center">
|
|
||||||
<p className="text-xs font-bold text-[#111827]">{f.label}</p>
|
|
||||||
<p className="text-[9px] text-gray-500 mt-0.5">{f.desc}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -194,7 +417,7 @@ function WhatsAppPanel() {
|
|||||||
<div className="bg-white border border-[#F59E0B]/30 p-6">
|
<div className="bg-white border border-[#F59E0B]/30 p-6">
|
||||||
<div className="flex items-center gap-2 mb-4">
|
<div className="flex items-center gap-2 mb-4">
|
||||||
<h3 className="text-base font-bold text-[#111827]">WhatsApp</h3>
|
<h3 className="text-base font-bold text-[#111827]">WhatsApp</h3>
|
||||||
<span className="text-[10px] font-bold px-1.5 py-0.5 bg-[#F59E0B]/10 text-[#F59E0B] flex items-center gap-1"><QrCode className="h-2.5 w-2.5" /> Scan QR code</span>
|
<span className="text-[10px] font-bold px-1.5 py-0.5 bg-[#F59E0B]/10 text-[#F59E0B] flex items-center gap-1"><QrCode className="h-2.5 w-2.5" /> Scan QR</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-center gap-4">
|
<div className="flex flex-col items-center gap-4">
|
||||||
{qrImage ? (
|
{qrImage ? (
|
||||||
@@ -203,18 +426,13 @@ function WhatsAppPanel() {
|
|||||||
<img src={qrImage} alt="WhatsApp QR Code" className="w-[200%] h-auto max-w-none" style={{ marginLeft: "-30%", marginTop: "-35%" }} />
|
<img src={qrImage} alt="WhatsApp QR Code" className="w-[200%] h-auto max-w-none" style={{ marginLeft: "-30%", marginTop: "-35%" }} />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-64 h-64 border-2 border-dashed border-gray-200 flex items-center justify-center">
|
<div className="w-64 h-64 border-2 border-dashed border-gray-200 flex items-center justify-center"><Loader2 className="h-6 w-6 text-gray-400 animate-spin" /></div>
|
||||||
<Loader2 className="h-6 w-6 text-gray-400 animate-spin" />
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
<div className="text-center space-y-1">
|
<div className="text-center space-y-1">
|
||||||
<p className="text-sm font-bold text-[#111827]">Scan with your phone</p>
|
<p className="text-sm font-bold text-[#111827]">Scan with your phone</p>
|
||||||
<p className="text-xs text-gray-500">Open WhatsApp → Settings → Linked Devices → Link a Device</p>
|
<p className="text-xs text-gray-500">WhatsApp → Settings → Linked Devices → Link a Device</p>
|
||||||
<p className="text-[10px] text-gray-400">Auto-refreshes every 5 seconds</p>
|
|
||||||
</div>
|
</div>
|
||||||
<button onClick={checkStatus} className="border border-gray-200 px-3 py-1.5 text-xs font-semibold text-gray-600 hover:bg-gray-50 flex items-center gap-1.5">
|
<button onClick={checkStatus} className="border border-gray-200 px-3 py-1.5 text-xs font-semibold text-gray-600 hover:bg-gray-50 flex items-center gap-1.5"><RefreshCw className="h-3 w-3" /> Refresh</button>
|
||||||
<RefreshCw className="h-3 w-3" /> Refresh
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -233,15 +451,11 @@ function WhatsAppPanel() {
|
|||||||
<p className="font-medium text-gray-600">• Pledge receipts with bank details</p>
|
<p className="font-medium text-gray-600">• Pledge receipts with bank details</p>
|
||||||
<p className="font-medium text-gray-600">• Payment reminders on a 4-step schedule</p>
|
<p className="font-medium text-gray-600">• Payment reminders on a 4-step schedule</p>
|
||||||
<p className="font-medium text-gray-600">• A chatbot (they reply PAID, HELP, or CANCEL)</p>
|
<p className="font-medium text-gray-600">• A chatbot (they reply PAID, HELP, or CANCEL)</p>
|
||||||
<p className="font-medium text-gray-600">• Volunteer notifications on each pledge</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={startSession} disabled={starting} className="mt-4 w-full bg-[#25D366] px-4 py-2.5 text-sm font-bold text-white hover:bg-[#25D366]/90 disabled:opacity-50 transition-colors flex items-center justify-center gap-2">
|
<button onClick={startSession} disabled={starting} className="mt-4 w-full bg-[#25D366] px-4 py-2.5 text-sm font-bold text-white hover:bg-[#25D366]/90 disabled:opacity-50 transition-colors flex items-center justify-center gap-2">
|
||||||
{starting ? <><Loader2 className="h-4 w-4 animate-spin" /> Starting...</> : <><MessageCircle className="h-4 w-4" /> Connect WhatsApp</>}
|
{starting ? <><Loader2 className="h-4 w-4 animate-spin" /> Starting...</> : <><MessageCircle className="h-4 w-4" /> Connect WhatsApp</>}
|
||||||
</button>
|
</button>
|
||||||
<p className="text-[10px] text-gray-400 text-center mt-2">
|
|
||||||
Free — no WhatsApp Business API required
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
429
temp_files/v3/AppealResource.php
Normal file
429
temp_files/v3/AppealResource.php
Normal file
@@ -0,0 +1,429 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
|
use App\Filament\Resources\AppealResource\Pages\EditAppeal;
|
||||||
|
use App\Filament\Resources\AppealResource\Pages\ListAppeals;
|
||||||
|
use App\Filament\Resources\AppealResource\RelationManagers\AppealChildrenRelationManager;
|
||||||
|
use App\Filament\Resources\AppealResource\RelationManagers\AppealDonationsRelationManager;
|
||||||
|
use App\Jobs\N3O\Data\SendAppeal;
|
||||||
|
use App\Jobs\WordPress\SyncAppeal;
|
||||||
|
use App\Models\Appeal;
|
||||||
|
use App\Models\DonationType;
|
||||||
|
use Filament\Forms\Components\Fieldset;
|
||||||
|
use Filament\Forms\Components\FileUpload;
|
||||||
|
use Filament\Forms\Components\RichEditor;
|
||||||
|
use Filament\Forms\Components\Section;
|
||||||
|
use Filament\Forms\Components\Select;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Forms\Components\Toggle;
|
||||||
|
use Filament\Forms\Form;
|
||||||
|
use Filament\GlobalSearch\Actions\Action as GlobalSearchAction;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Resources\Resource;
|
||||||
|
use Filament\Support\Enums\MaxWidth;
|
||||||
|
use Filament\Tables\Actions\Action;
|
||||||
|
use Filament\Tables\Actions\ActionGroup;
|
||||||
|
use Filament\Tables\Actions\BulkAction;
|
||||||
|
use Filament\Tables\Actions\EditAction;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Enums\FiltersLayout;
|
||||||
|
use Filament\Tables\Filters\Filter;
|
||||||
|
use Filament\Tables\Filters\SelectFilter;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\HtmlString;
|
||||||
|
|
||||||
|
class AppealResource extends Resource
|
||||||
|
{
|
||||||
|
protected static ?string $model = Appeal::class;
|
||||||
|
|
||||||
|
protected static ?string $navigationIcon = 'heroicon-o-hand-raised';
|
||||||
|
|
||||||
|
protected static ?string $navigationGroup = 'Campaigns';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 1;
|
||||||
|
|
||||||
|
protected static ?string $modelLabel = 'Fundraiser';
|
||||||
|
|
||||||
|
protected static ?string $pluralModelLabel = 'Fundraisers';
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'Fundraisers';
|
||||||
|
|
||||||
|
protected static ?string $recordTitleAttribute = 'name';
|
||||||
|
|
||||||
|
// ─── Global Search ────────────────────────────────────────────
|
||||||
|
|
||||||
|
public static function getGloballySearchableAttributes(): array
|
||||||
|
{
|
||||||
|
return ['name', 'slug', 'description'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getGlobalSearchResultTitle(Model $record): string|HtmlString
|
||||||
|
{
|
||||||
|
return $record->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'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
493
temp_files/v3/DonationResource.php
Normal file
493
temp_files/v3/DonationResource.php
Normal file
@@ -0,0 +1,493 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
|
use App\Definitions\DonationOccurrence;
|
||||||
|
use App\Filament\Exports\DonationExporter;
|
||||||
|
use App\Filament\Resources\DonationResource\Pages\EditDonation;
|
||||||
|
use App\Filament\Resources\DonationResource\Pages\ListDonations;
|
||||||
|
use App\Filament\Resources\DonationResource\RelationManagers\EventLogsRelationManager;
|
||||||
|
use App\Filament\RelationManagers\InternalNotesRelationManager;
|
||||||
|
use App\Helpers;
|
||||||
|
use App\Jobs\N3O\Data\SendDonation;
|
||||||
|
use App\Jobs\Zapier\Data\SendCustomer;
|
||||||
|
use App\Mail\DonationConfirmed;
|
||||||
|
use App\Models\Donation;
|
||||||
|
use App\Models\DonationCountry;
|
||||||
|
use App\Models\DonationType;
|
||||||
|
use App\Models\EverGiveDonation;
|
||||||
|
use Filament\Forms\Components\DatePicker;
|
||||||
|
use Filament\Forms\Components\Fieldset;
|
||||||
|
use Filament\Forms\Components\Grid;
|
||||||
|
use Filament\Forms\Components\Placeholder;
|
||||||
|
use Filament\Forms\Components\Section;
|
||||||
|
use Filament\Forms\Components\Select;
|
||||||
|
use Filament\Forms\Form;
|
||||||
|
use Filament\GlobalSearch\Actions\Action as GlobalSearchAction;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Resources\Resource;
|
||||||
|
use Filament\Tables\Actions\Action;
|
||||||
|
use Filament\Tables\Actions\ActionGroup;
|
||||||
|
use Filament\Tables\Actions\BulkAction;
|
||||||
|
use Filament\Tables\Actions\BulkActionGroup;
|
||||||
|
use Filament\Tables\Actions\ExportAction;
|
||||||
|
use Filament\Tables\Actions\ViewAction;
|
||||||
|
use Filament\Tables\Columns\IconColumn;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Enums\FiltersLayout;
|
||||||
|
use Filament\Tables\Filters\Filter;
|
||||||
|
use Filament\Tables\Filters\SelectFilter;
|
||||||
|
use Filament\Tables\Filters\TernaryFilter;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
use Illuminate\Support\HtmlString;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
class DonationResource extends Resource
|
||||||
|
{
|
||||||
|
protected static ?string $model = Donation::class;
|
||||||
|
|
||||||
|
protected static ?string $navigationIcon = 'heroicon-o-banknotes';
|
||||||
|
|
||||||
|
protected static ?string $navigationGroup = 'Donations';
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'All Donations';
|
||||||
|
|
||||||
|
protected static ?string $modelLabel = 'Donation';
|
||||||
|
|
||||||
|
protected static ?string $pluralModelLabel = 'Donations';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 1;
|
||||||
|
|
||||||
|
// ─── Global Search ────────────────────────────────────────────
|
||||||
|
// Staff search by donor name, email, or payment reference.
|
||||||
|
// "Someone called about payment ref pi_3xxx" → finds it instantly.
|
||||||
|
|
||||||
|
public static function getGloballySearchableAttributes(): array
|
||||||
|
{
|
||||||
|
return ['reference_code', 'provider_reference'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getGlobalSearchResultTitle(Model $record): string|HtmlString
|
||||||
|
{
|
||||||
|
$status = $record->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()
|
||||||
|
? '<span class="text-green-600 font-bold">✓ Confirmed</span>'
|
||||||
|
: '<span class="text-red-600 font-bold">✗ Incomplete</span>'
|
||||||
|
)),
|
||||||
|
|
||||||
|
Placeholder::make('Amount')
|
||||||
|
->content(fn (Donation $record) => new HtmlString('<b>' . Helpers::formatMoneyGlobal($record->donationTotal()) . '</b>')),
|
||||||
|
|
||||||
|
Placeholder::make('Admin Contribution')
|
||||||
|
->content(fn (Donation $record) => new HtmlString('<b>' . Helpers::formatMoneyGlobal($record->donationAdminAmount()) . '</b>')),
|
||||||
|
|
||||||
|
Placeholder::make('Gift Aid?')
|
||||||
|
->content(fn (Donation $record) => new HtmlString(
|
||||||
|
$record->isGiftAid()
|
||||||
|
? '<span class="text-green-600 font-bold">✓ Yes</span>'
|
||||||
|
: '<span class="text-gray-400">No</span>'
|
||||||
|
)),
|
||||||
|
|
||||||
|
Placeholder::make('Zakat?')
|
||||||
|
->content(fn (Donation $record) => new HtmlString(
|
||||||
|
$record->isZakat()
|
||||||
|
? '<span class="text-green-600 font-bold">✓ Yes</span>'
|
||||||
|
: '<span class="text-gray-400">No</span>'
|
||||||
|
)),
|
||||||
|
]),
|
||||||
|
|
||||||
|
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('<i class="text-gray-400">Not provided</i>')),
|
||||||
|
])
|
||||||
|
->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('<i class="text-gray-400">—</i>')),
|
||||||
|
|
||||||
|
Placeholder::make('street')
|
||||||
|
->content(fn (Donation $donation) => strlen(trim($v = $donation->address?->street ?? '')) ? $v : new HtmlString('<i class="text-gray-400">—</i>')),
|
||||||
|
|
||||||
|
Placeholder::make('town')
|
||||||
|
->content(fn (Donation $donation) => strlen(trim($v = $donation->address?->town ?? '')) ? $v : new HtmlString('<i class="text-gray-400">—</i>')),
|
||||||
|
|
||||||
|
Placeholder::make('state')
|
||||||
|
->content(fn (Donation $donation) => strlen(trim($v = $donation->address?->state ?? '')) ? $v : new HtmlString('<i class="text-gray-400">—</i>')),
|
||||||
|
|
||||||
|
Placeholder::make('postcode')
|
||||||
|
->content(fn (Donation $donation) => strlen(trim($v = $donation->address?->postcode ?? '')) ? $v : new HtmlString('<i class="text-gray-400">—</i>')),
|
||||||
|
|
||||||
|
Placeholder::make('country_code')
|
||||||
|
->content(fn (Donation $donation) => strlen(trim($v = $donation->address?->country_code ?? '')) ? $v : new HtmlString('<i class="text-gray-400">—</i>')),
|
||||||
|
])
|
||||||
|
->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')),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
127
temp_files/v3/FundraiserNurtureWidget.php
Normal file
127
temp_files/v3/FundraiserNurtureWidget.php
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Widgets;
|
||||||
|
|
||||||
|
use App\Filament\Resources\AppealResource;
|
||||||
|
use App\Models\Appeal;
|
||||||
|
use Filament\Tables\Actions\Action;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Filament\Widgets\TableWidget as BaseWidget;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows fundraisers that need staff attention right now.
|
||||||
|
*
|
||||||
|
* This is the "nurture queue" — it surfaces fundraisers that are
|
||||||
|
* stalling, almost succeeding, or brand new, so Jasmine can
|
||||||
|
* proactively help supporters succeed.
|
||||||
|
*/
|
||||||
|
class FundraiserNurtureWidget extends BaseWidget
|
||||||
|
{
|
||||||
|
protected static ?int $sort = 5;
|
||||||
|
|
||||||
|
protected int | string | array $columnSpan = 'full';
|
||||||
|
|
||||||
|
protected static ?string $heading = 'Fundraisers That Need Your Help';
|
||||||
|
|
||||||
|
protected int $defaultPaginationPageOption = 5;
|
||||||
|
|
||||||
|
public function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->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');
|
||||||
|
}
|
||||||
|
}
|
||||||
40
temp_files/v3/ListAppeals.php
Normal file
40
temp_files/v3/ListAppeals.php
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\AppealResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\AppealResource;
|
||||||
|
use App\Models\Appeal;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
|
class ListAppeals extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = AppealResource::class;
|
||||||
|
|
||||||
|
public function getHeading(): string
|
||||||
|
{
|
||||||
|
return 'All Fundraisers';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSubheading(): string
|
||||||
|
{
|
||||||
|
$live = Appeal::where('status', 'confirmed')->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.';
|
||||||
|
}
|
||||||
|
}
|
||||||
40
temp_files/v3/ListDonations.php
Normal file
40
temp_files/v3/ListDonations.php
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\DonationResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\DonationResource;
|
||||||
|
use App\Models\Donation;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
|
class ListDonations extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = DonationResource::class;
|
||||||
|
|
||||||
|
public function getHeading(): string
|
||||||
|
{
|
||||||
|
return 'All Donations';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSubheading(): string
|
||||||
|
{
|
||||||
|
$todayConfirmed = Donation::whereHas('donationConfirmation', fn ($q) => $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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user