Skip to content

Commit 74e140f

Browse files
committed
Event registration
1 parent e0cffe4 commit 74e140f

9 files changed

Lines changed: 424 additions & 12 deletions

File tree

package-lock.json

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
"@churchapps/apphelper-markdown": "0.6.15",
1212
"@churchapps/apphelper-website": "0.6.17",
1313
"@churchapps/content-providers": "0.1.0",
14-
"@churchapps/helpers": "1.2.26",
14+
"@churchapps/helpers": "1.2.27",
1515
"@mui/icons-material": "^7.1.2",
1616
"@mui/material": "^7.1.2",
1717
"@react-google-maps/api": "^2.20.0",

src/Authenticated.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ const LiveStreamTimesPage = React.lazy(() => import("./sermons/LiveStreamTimesPa
4747
const BulkImportPage = React.lazy(() => import("./sermons/BulkImportPage").then((module) => ({ default: module.BulkImportPage })));
4848
const CalendarsPage = React.lazy(() => import("./calendars/CalendarsPage").then((module) => ({ default: module.CalendarsPage })));
4949
const CalendarPage = React.lazy(() => import("./calendars/CalendarPage").then((module) => ({ default: module.CalendarPage })));
50+
const RegistrationsPage = React.lazy(() => import("./registrations/RegistrationsPage").then((module) => ({ default: module.RegistrationsPage })));
51+
const RegistrationDetailsPage = React.lazy(() => import("./registrations/RegistrationDetailsPage").then((module) => ({ default: module.RegistrationDetailsPage })));
5052
const Site = React.lazy(() => import("./site").then((module) => ({ default: module.Site })));
5153

5254
// Loading component for Suspense fallback
@@ -123,6 +125,8 @@ export const Authenticated: React.FC = () => {
123125
<Route path="/sermons/times" element={<LiveStreamTimesPage />} />
124126
<Route path="/sermons/bulk" element={<BulkImportPage />} />
125127
<Route path="/sermons" element={<SermonsPage />} />
128+
<Route path="/registrations/:eventId" element={<RegistrationDetailsPage />} />
129+
<Route path="/registrations" element={<RegistrationsPage />} />
126130
<Route path="/calendars/:id" element={<CalendarPage />} />
127131
<Route path="/calendars" element={<CalendarsPage />} />
128132
<Route path="/site/*" element={<Site />} />

src/components/Header.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ export const Header: React.FC = () => {
6262
else if (path.startsWith("/donations")) result = Locale.label("components.wrapper.don");
6363
else if (path.startsWith("/serving") || window.location.search.indexOf("tag=") > -1) result = Locale.label("components.wrapper.serving");
6464
else if (path.startsWith("/sermons")) result = Locale.label("common.sermons");
65-
else if (path.startsWith("/calendars") || path.startsWith("/site")) result = Locale.label("common.website");
65+
else if (path.startsWith("/calendars") || path.startsWith("/site") || path.startsWith("/registrations")) result = Locale.label("common.website");
6666
else if (path.startsWith("/settings") || path.startsWith("/admin") || path.startsWith("/forms")) result = Locale.label("components.wrapper.set");
6767
return result;
6868
};

src/helpers/SecondaryMenuHelper.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export class SecondaryMenuHelper {
1414
else if (path.startsWith("/settings") || path.startsWith("/admin") || path.startsWith("/forms")) result = this.getSettingsMenu(path, data);
1515
else if (path.startsWith("/serving")) result = this.getServingMenu(path);
1616
else if (path.startsWith("/donations")) result = this.getDonationsMenu(path);
17-
else if (path.startsWith("/site") || path.startsWith("/calendars")) result = this.getSiteMenu(path);
17+
else if (path.startsWith("/site") || path.startsWith("/calendars") || path.startsWith("/registrations")) result = this.getSiteMenu(path);
1818
else if (path.startsWith("/sermons")) result = this.getSermonsMenu(path);
1919
else if (path.startsWith("/profile")) result = this.getProfileMenu(path);
2020
else if (path === "/") result = this.getDashboardMenu(path);
@@ -114,8 +114,10 @@ export class SecondaryMenuHelper {
114114
menuItems.push({ url: "/site/appearance", label: "Appearance", icon: "palette" });
115115
menuItems.push({ url: "/site/files", label: "Files", icon: "folder_open" });
116116
menuItems.push({ url: "/calendars", label: "Calendars", icon: "calendar_month" });
117+
menuItems.push({ url: "/registrations", label: "Registrations", icon: "how_to_reg" });
117118

118-
if (path.startsWith("/site/pages")) label = "Pages";
119+
if (path.startsWith("/registrations")) label = "Registrations";
120+
else if (path.startsWith("/site/pages")) label = "Pages";
119121
else if (path.startsWith("/site/blocks")) label = "Blocks";
120122
else if (path.startsWith("/site/appearance")) label = "Appearance";
121123
else if (path.startsWith("/site/files")) label = "Files";
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import React, { useEffect, useState } from "react";
2+
import { useParams } from "react-router-dom";
3+
import {
4+
Typography,
5+
Table,
6+
TableBody,
7+
TableRow,
8+
TableCell,
9+
TableHead,
10+
Card,
11+
Box,
12+
Stack,
13+
Chip,
14+
Button,
15+
IconButton,
16+
Tooltip,
17+
LinearProgress,
18+
Grid
19+
} from "@mui/material";
20+
import {
21+
HowToReg as RegIcon,
22+
Cancel as CancelIcon,
23+
Delete as DeleteIcon,
24+
Download as DownloadIcon
25+
} from "@mui/icons-material";
26+
import { ApiHelper, Loading, PageHeader } from "@churchapps/apphelper";
27+
import { type EventInterface, type RegistrationInterface } from "@churchapps/helpers";
28+
import { RegistrationSettingsEdit } from "./components/RegistrationSettingsEdit";
29+
30+
export const RegistrationDetailsPage = () => {
31+
const params = useParams();
32+
const eventId = params.eventId;
33+
const [event, setEvent] = useState<EventInterface | null>(null);
34+
const [registrations, setRegistrations] = useState<RegistrationInterface[]>([]);
35+
const [loading, setLoading] = useState(true);
36+
const [count, setCount] = useState(0);
37+
38+
const loadData = async () => {
39+
if (!eventId) return;
40+
setLoading(true);
41+
const [eventData, regsData] = await Promise.all([
42+
ApiHelper.get("/events/" + eventId, "ContentApi"),
43+
ApiHelper.get("/registrations/event/" + eventId, "ContentApi")
44+
]);
45+
setEvent(eventData);
46+
setRegistrations(regsData || []);
47+
setCount((regsData || []).filter((r: RegistrationInterface) => r.status !== "cancelled").length);
48+
setLoading(false);
49+
};
50+
51+
useEffect(() => { loadData(); }, [eventId]);
52+
53+
const handleCancel = async (regId: string) => {
54+
if (!confirm("Cancel this registration?")) return;
55+
await ApiHelper.post("/registrations/" + regId + "/cancel", {}, "ContentApi");
56+
loadData();
57+
};
58+
59+
const handleDelete = async (regId: string) => {
60+
if (!confirm("Permanently delete this registration?")) return;
61+
await ApiHelper.delete("/registrations/" + regId, "ContentApi");
62+
loadData();
63+
};
64+
65+
const handleExportCSV = () => {
66+
const rows = [["Name", "Members", "Status", "Date"]];
67+
registrations.forEach((reg) => {
68+
const members = reg.members?.map((m) => `${m.firstName} ${m.lastName}`).join("; ") || "";
69+
rows.push([
70+
reg.personId || "Guest",
71+
members,
72+
reg.status || "",
73+
reg.registeredDate ? new Date(reg.registeredDate).toLocaleDateString() : ""
74+
]);
75+
});
76+
const csv = rows.map((r) => r.map((c) => `"${c}"`).join(",")).join("\n");
77+
const blob = new Blob([csv], { type: "text/csv" });
78+
const url = URL.createObjectURL(blob);
79+
const a = document.createElement("a");
80+
a.href = url;
81+
a.download = `registrations-${event?.title || eventId}.csv`;
82+
a.click();
83+
URL.revokeObjectURL(url);
84+
};
85+
86+
const getStatusChip = (status: string) => {
87+
const colorMap: Record<string, "success" | "warning" | "error" | "default"> = {
88+
confirmed: "success",
89+
pending: "warning",
90+
cancelled: "error",
91+
waitlisted: "default"
92+
};
93+
return <Chip label={status} size="small" color={colorMap[status] || "default"} />;
94+
};
95+
96+
const getRows = () => registrations.map((reg) => (
97+
<TableRow key={reg.id}>
98+
<TableCell>
99+
{reg.members && reg.members.length > 0
100+
? reg.members.map((m) => `${m.firstName} ${m.lastName}`).join(", ")
101+
: reg.personId || "Unknown"
102+
}
103+
</TableCell>
104+
<TableCell>{reg.members?.length || 0}</TableCell>
105+
<TableCell>{getStatusChip(reg.status)}</TableCell>
106+
<TableCell>{reg.registeredDate ? new Date(reg.registeredDate).toLocaleDateString() : ""}</TableCell>
107+
<TableCell align="right">
108+
{reg.status !== "cancelled" && (
109+
<Tooltip title="Cancel Registration" arrow>
110+
<IconButton size="small" onClick={() => handleCancel(reg.id)} color="warning"><CancelIcon fontSize="small" /></IconButton>
111+
</Tooltip>
112+
)}
113+
<Tooltip title="Delete" arrow>
114+
<IconButton size="small" onClick={() => handleDelete(reg.id)} color="error"><DeleteIcon fontSize="small" /></IconButton>
115+
</Tooltip>
116+
</TableCell>
117+
</TableRow>
118+
));
119+
120+
if (loading) return <Box sx={{ p: 3, textAlign: "center" }}><Loading /></Box>;
121+
if (!event) return <Typography>Event not found</Typography>;
122+
123+
const capacityPct = event.capacity ? Math.min((count / event.capacity) * 100, 100) : 0;
124+
125+
return (
126+
<>
127+
<PageHeader icon={<RegIcon />} title={event.title || "Event Registrations"} subtitle="Manage registrations for this event" />
128+
<Box sx={{ p: 3 }}>
129+
<Grid container spacing={3}>
130+
<Grid size={{ xs: 12, md: 8 }}>
131+
<Card sx={{ borderRadius: 2, border: "1px solid", borderColor: "grey.200" }}>
132+
<Box sx={{ p: 2, borderBottom: 1, borderColor: "divider" }}>
133+
<Stack direction="row" spacing={1} alignItems="center" justifyContent="space-between">
134+
<Stack direction="row" spacing={1} alignItems="center">
135+
<RegIcon sx={{ color: "primary.main" }} />
136+
<Typography variant="h6" sx={{ fontWeight: 600, color: "primary.main" }}>
137+
Registrations ({count}{event.capacity ? ` / ${event.capacity}` : ""})
138+
</Typography>
139+
</Stack>
140+
<Button startIcon={<DownloadIcon />} size="small" onClick={handleExportCSV}>Export CSV</Button>
141+
</Stack>
142+
{event.capacity && (
143+
<LinearProgress variant="determinate" value={capacityPct} color={capacityPct >= 100 ? "error" : "primary"} sx={{ mt: 1 }} />
144+
)}
145+
</Box>
146+
{registrations.length === 0 ? (
147+
<Box sx={{ p: 3, textAlign: "center" }}>
148+
<Typography variant="body2" color="text.secondary">No registrations yet.</Typography>
149+
</Box>
150+
) : (
151+
<Table size="small">
152+
<TableHead sx={{ backgroundColor: "#f5f5f5" }}>
153+
<TableRow>
154+
<TableCell sx={{ fontWeight: 600 }}>Name</TableCell>
155+
<TableCell sx={{ fontWeight: 600 }}>Members</TableCell>
156+
<TableCell sx={{ fontWeight: 600 }}>Status</TableCell>
157+
<TableCell sx={{ fontWeight: 600 }}>Date</TableCell>
158+
<TableCell sx={{ fontWeight: 600 }} align="right">Actions</TableCell>
159+
</TableRow>
160+
</TableHead>
161+
<TableBody>{getRows()}</TableBody>
162+
</Table>
163+
)}
164+
</Card>
165+
</Grid>
166+
167+
<Grid size={{ xs: 12, md: 4 }}>
168+
<RegistrationSettingsEdit event={event} onUpdate={loadData} />
169+
</Grid>
170+
</Grid>
171+
</Box>
172+
</>
173+
);
174+
};
175+
176+
export default RegistrationDetailsPage;
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import React, { useEffect, useState } from "react";
2+
import { useNavigate } from "react-router-dom";
3+
import {
4+
Typography,
5+
Table,
6+
TableBody,
7+
TableRow,
8+
TableCell,
9+
TableHead,
10+
Card,
11+
Box,
12+
Stack,
13+
Chip,
14+
LinearProgress
15+
} from "@mui/material";
16+
import { HowToReg as RegIcon } from "@mui/icons-material";
17+
import { ApiHelper, Loading, PageHeader } from "@churchapps/apphelper";
18+
import { type EventInterface } from "@churchapps/helpers";
19+
20+
export const RegistrationsPage = () => {
21+
const navigate = useNavigate();
22+
const [events, setEvents] = useState<EventInterface[]>([]);
23+
const [counts, setCounts] = useState<Record<string, number>>({});
24+
const [loading, setLoading] = useState(true);
25+
26+
const loadData = async () => {
27+
setLoading(true);
28+
const data: EventInterface[] = await ApiHelper.get("/events/registerable", "ContentApi");
29+
setEvents(data || []);
30+
31+
// Load registration counts for each event
32+
const countMap: Record<string, number> = {};
33+
if (data?.length > 0) {
34+
await Promise.all(data.map(async (event) => {
35+
const result = await ApiHelper.get("/registrations/event/" + event.id + "/count?churchId=" + event.churchId, "ContentApi");
36+
countMap[event.id] = result?.count || 0;
37+
}));
38+
}
39+
setCounts(countMap);
40+
setLoading(false);
41+
};
42+
43+
useEffect(() => { loadData(); }, []);
44+
45+
const getCapacityDisplay = (event: EventInterface) => {
46+
const count = counts[event.id] || 0;
47+
if (!event.capacity) return <Typography variant="body2">{count} registered</Typography>;
48+
const pct = Math.min((count / event.capacity) * 100, 100);
49+
return (
50+
<Box sx={{ minWidth: 120 }}>
51+
<Typography variant="body2">{count} / {event.capacity}</Typography>
52+
<LinearProgress variant="determinate" value={pct} color={pct >= 100 ? "error" : "primary"} sx={{ mt: 0.5 }} />
53+
</Box>
54+
);
55+
};
56+
57+
const getRows = () => events.map((event) => (
58+
<TableRow key={event.id} hover sx={{ cursor: "pointer" }} onClick={() => navigate("/registrations/" + event.id)}>
59+
<TableCell><Typography variant="body2" fontWeight={500}>{event.title}</Typography></TableCell>
60+
<TableCell>{event.start ? new Date(event.start).toLocaleDateString() : ""}</TableCell>
61+
<TableCell>{getCapacityDisplay(event)}</TableCell>
62+
<TableCell>
63+
{event.tags && event.tags.split(",").map((tag) => (
64+
<Chip key={tag} label={tag.trim()} size="small" sx={{ mr: 0.5 }} />
65+
))}
66+
</TableCell>
67+
</TableRow>
68+
));
69+
70+
return (
71+
<>
72+
<PageHeader icon={<RegIcon />} title="Event Registrations" subtitle="Manage event registration settings and view registrants" />
73+
<Box sx={{ p: 3 }}>
74+
<Card sx={{ borderRadius: 2, border: "1px solid", borderColor: "grey.200" }}>
75+
<Box sx={{ p: 2, borderBottom: 1, borderColor: "divider" }}>
76+
<Stack direction="row" spacing={1} alignItems="center">
77+
<RegIcon sx={{ color: "primary.main" }} />
78+
<Typography variant="h6" sx={{ fontWeight: 600, color: "primary.main" }}>
79+
Events with Registration Enabled
80+
</Typography>
81+
</Stack>
82+
</Box>
83+
{loading ? (
84+
<Box sx={{ p: 3, textAlign: "center" }}><Loading /></Box>
85+
) : events.length === 0 ? (
86+
<Box sx={{ p: 3, textAlign: "center" }}>
87+
<RegIcon sx={{ fontSize: 48, color: "grey.400", mb: 1 }} />
88+
<Typography variant="body2" color="text.secondary">
89+
No events have registration enabled yet.
90+
</Typography>
91+
<Typography variant="caption" color="text.secondary">
92+
Enable registration on an event through the event edit settings.
93+
</Typography>
94+
</Box>
95+
) : (
96+
<Table>
97+
<TableHead sx={{ backgroundColor: "#f5f5f5" }}>
98+
<TableRow>
99+
<TableCell sx={{ fontWeight: 600 }}>Event</TableCell>
100+
<TableCell sx={{ fontWeight: 600 }}>Date</TableCell>
101+
<TableCell sx={{ fontWeight: 600 }}>Registrations</TableCell>
102+
<TableCell sx={{ fontWeight: 600 }}>Tags</TableCell>
103+
</TableRow>
104+
</TableHead>
105+
<TableBody>{getRows()}</TableBody>
106+
</Table>
107+
)}
108+
</Card>
109+
</Box>
110+
</>
111+
);
112+
};
113+
114+
export default RegistrationsPage;

0 commit comments

Comments
 (0)