mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-23 15:18:04 +02:00
separate meetings and add member registrations
This commit is contained in:
1 parent
b2d428b08b
commit
afc805c96a
17 files changed
+2113
-372
No files matched your search
@@ -1,23 +1,15 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import type { CSSProperties } from "react";
|
||||
import { asc, desc, eq, sql } from "drizzle-orm";
|
||||
import { desc, eq, sql } from "drizzle-orm";
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/db";
|
||||
import { meetingRegistrations, meetings, pastMeetingPhotos, pastMeetings } from "@/db/schema";
|
||||
import { meetingRegistrations, meetings } from "@/db/schema";
|
||||
import { ImageField } from "@/components/ImageField";
|
||||
import { MeetingEmailComposer } from "@/components/MeetingEmailComposer";
|
||||
import { can } from "@/lib/rbac";
|
||||
import { getSiteSettings } from "@/lib/site-settings";
|
||||
import {
|
||||
addPastMeetingPhoto,
|
||||
createMeeting,
|
||||
createPastMeeting,
|
||||
deleteMeeting,
|
||||
deletePastMeeting,
|
||||
deletePastMeetingPhoto,
|
||||
updateMeeting,
|
||||
updatePastMeeting,
|
||||
} from "../actions";
|
||||
import { createMeeting, deleteMeeting, updateMeeting } from "../actions";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -27,38 +19,29 @@ function dateTimeValue(date: Date) {
|
||||
return local.toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
function dateValue(date: Date) {
|
||||
const local = new Date(date);
|
||||
local.setMinutes(local.getMinutes() - local.getTimezoneOffset());
|
||||
return local.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export default async function RencontresAdminPage() {
|
||||
const session = await auth();
|
||||
if (!can(session?.user.role, "manageMeetings")) redirect("/backend");
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: meetings.id,
|
||||
title: meetings.title,
|
||||
startsAt: meetings.startsAt,
|
||||
location: meetings.location,
|
||||
description: meetings.description,
|
||||
capacity: meetings.capacity,
|
||||
imageUrl: meetings.imageUrl,
|
||||
registered: sql<number>`count(${meetingRegistrations.id})`,
|
||||
})
|
||||
.from(meetings)
|
||||
.leftJoin(meetingRegistrations, eq(meetingRegistrations.meetingId, meetings.id))
|
||||
.groupBy(meetings.id)
|
||||
.orderBy(desc(meetings.startsAt));
|
||||
|
||||
const archives = await db.select().from(pastMeetings).orderBy(desc(pastMeetings.eventDate));
|
||||
const photos = await db
|
||||
.select()
|
||||
.from(pastMeetingPhotos)
|
||||
.orderBy(asc(pastMeetingPhotos.position), asc(pastMeetingPhotos.id));
|
||||
const settings = await getSiteSettings();
|
||||
const [rows, settings] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: meetings.id,
|
||||
title: meetings.title,
|
||||
startsAt: meetings.startsAt,
|
||||
location: meetings.location,
|
||||
description: meetings.description,
|
||||
capacity: meetings.capacity,
|
||||
participantsPerAccount: meetings.participantsPerAccount,
|
||||
imageUrl: meetings.imageUrl,
|
||||
registered: sql<number>`count(${meetingRegistrations.id})`,
|
||||
})
|
||||
.from(meetings)
|
||||
.leftJoin(meetingRegistrations, eq(meetingRegistrations.meetingId, meetings.id))
|
||||
.groupBy(meetings.id)
|
||||
.orderBy(desc(meetings.startsAt)),
|
||||
getSiteSettings(),
|
||||
]);
|
||||
const emailBrand = {
|
||||
associationName: settings.association_name,
|
||||
address: settings.association_address,
|
||||
@@ -67,17 +50,10 @@ export default async function RencontresAdminPage() {
|
||||
siret: settings.association_siret,
|
||||
};
|
||||
|
||||
const photosByPast = new Map<number, typeof photos>();
|
||||
for (const photo of photos) {
|
||||
photosByPast.set(photo.pastMeetingId, [...(photosByPast.get(photo.pastMeetingId) ?? []), photo]);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "grid", gap: 28 }}>
|
||||
<section style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 16, padding: 22 }}>
|
||||
<h2 className="font-display" style={{ margin: "0 0 16px", fontSize: 20, color: "#26201a" }}>
|
||||
Nouvelle rencontre
|
||||
</h2>
|
||||
<section style={panelStyle}>
|
||||
<h2 className="font-display" style={{ margin: "0 0 16px", fontSize: 20, color: "#26201a" }}>Nouvelle rencontre</h2>
|
||||
<form action={createMeeting} className="grid grid-2" style={{ gap: 16 }}>
|
||||
<div>
|
||||
<label className="field-label">Titre</label>
|
||||
@@ -91,9 +67,15 @@ export default async function RencontresAdminPage() {
|
||||
<label className="field-label">Lieu</label>
|
||||
<input name="location" className="field" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">Places</label>
|
||||
<input name="capacity" type="number" min="1" defaultValue={30} className="field" />
|
||||
<div className="grid grid-2" style={{ gap: 12 }}>
|
||||
<div>
|
||||
<label className="field-label">Places</label>
|
||||
<input name="capacity" type="number" min="1" defaultValue={30} className="field" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">Max. par compte</label>
|
||||
<input name="participantsPerAccount" type="number" min="1" max="100" defaultValue={1} className="field" />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ gridColumn: "1 / -1" }}>
|
||||
<label className="field-label">Description</label>
|
||||
@@ -102,14 +84,12 @@ export default async function RencontresAdminPage() {
|
||||
<div style={{ gridColumn: "1 / -1" }}>
|
||||
<ImageField name="imageUrl" label="Image" height={160} />
|
||||
</div>
|
||||
<button type="submit" style={primaryButton}>
|
||||
Ajouter la rencontre
|
||||
</button>
|
||||
<button type="submit" style={primaryButton}>Ajouter la rencontre</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div style={sectionTitle}>Rencontres à venir / publiées</div>
|
||||
<div style={sectionTitle}>Rencontres publiées</div>
|
||||
<div className="grid grid-3" style={{ gap: 18 }}>
|
||||
{rows.map((meeting) => (
|
||||
<article key={meeting.id} style={cardStyle}>
|
||||
@@ -128,19 +108,27 @@ export default async function RencontresAdminPage() {
|
||||
<label className="field-label">Lieu</label>
|
||||
<input name="location" defaultValue={meeting.location ?? ""} className="field" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">Places</label>
|
||||
<input name="capacity" type="number" min="1" defaultValue={meeting.capacity} className="field" />
|
||||
<div className="grid grid-2" style={{ gap: 10 }}>
|
||||
<div>
|
||||
<label className="field-label">Places</label>
|
||||
<input name="capacity" type="number" min="1" defaultValue={meeting.capacity} className="field" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">Max. / compte</label>
|
||||
<input name="participantsPerAccount" type="number" min="1" max="100" defaultValue={meeting.participantsPerAccount} className="field" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">Description</label>
|
||||
<textarea name="description" rows={3} defaultValue={meeting.description ?? ""} className="field" />
|
||||
</div>
|
||||
<div style={{ fontSize: 12.5, color: "#6c6150", fontWeight: 800 }}>
|
||||
{Number(meeting.registered)} inscrit(s)
|
||||
</div>
|
||||
<button type="submit" style={primaryButton}>Enregistrer</button>
|
||||
</form>
|
||||
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 12, marginTop: 14, paddingTop: 14, borderTop: "1px solid #f0e8d6" }}>
|
||||
<div style={{ fontSize: 12.5, color: "#6c6150", fontWeight: 800 }}>{Number(meeting.registered)} participant(s)</div>
|
||||
<Link href={`/backend/inscriptions?meeting=${meeting.id}`} style={{ color: "#9a6638", fontSize: 12.5, fontWeight: 800, textDecoration: "none" }}>Voir les inscrits</Link>
|
||||
</div>
|
||||
<MeetingEmailComposer
|
||||
meeting={{
|
||||
id: meeting.id,
|
||||
@@ -160,153 +148,15 @@ export default async function RencontresAdminPage() {
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 16, padding: 22 }}>
|
||||
<h2 className="font-display" style={{ margin: "0 0 16px", fontSize: 20, color: "#26201a" }}>
|
||||
Nouvelle rencontre passée
|
||||
</h2>
|
||||
<form action={createPastMeeting} className="grid grid-2" style={{ gap: 16 }}>
|
||||
<div>
|
||||
<label className="field-label">Titre</label>
|
||||
<input name="title" className="field" required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">Date</label>
|
||||
<input name="eventDate" type="date" className="field" required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">Lieu</label>
|
||||
<input name="location" className="field" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">Lier une rencontre</label>
|
||||
<select name="meetingId" className="field" defaultValue="">
|
||||
<option value="">Aucune</option>
|
||||
{rows.map((meeting) => (
|
||||
<option key={meeting.id} value={meeting.id}>
|
||||
{meeting.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ gridColumn: "1 / -1" }}>
|
||||
<label className="field-label">Description</label>
|
||||
<textarea name="description" rows={3} className="field" />
|
||||
</div>
|
||||
<div style={{ gridColumn: "1 / -1" }}>
|
||||
<label className="field-label">Participants manuels (un par ligne, si non liée)</label>
|
||||
<textarea name="participants" rows={3} className="field" />
|
||||
</div>
|
||||
<button type="submit" style={primaryButton}>Publier l'archive</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div style={sectionTitle}>Rencontres passées</div>
|
||||
<div className="grid grid-3" style={{ gap: 18 }}>
|
||||
{archives.map((archive) => (
|
||||
<article key={archive.id} style={cardStyle}>
|
||||
<form action={updatePastMeeting} style={{ display: "grid", gap: 12 }}>
|
||||
<input type="hidden" name="id" value={archive.id} />
|
||||
<div>
|
||||
<label className="field-label">Titre</label>
|
||||
<input name="title" defaultValue={archive.title} className="field" required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">Date</label>
|
||||
<input name="eventDate" type="date" defaultValue={dateValue(archive.eventDate)} className="field" required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">Lieu</label>
|
||||
<input name="location" defaultValue={archive.location ?? ""} className="field" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">Rencontre liée</label>
|
||||
<select name="meetingId" className="field" defaultValue={archive.meetingId ?? ""}>
|
||||
<option value="">Aucune</option>
|
||||
{rows.map((meeting) => (
|
||||
<option key={meeting.id} value={meeting.id}>
|
||||
{meeting.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">Description</label>
|
||||
<textarea name="description" rows={3} defaultValue={archive.description ?? ""} className="field" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">Participants manuels</label>
|
||||
<textarea name="participants" rows={3} defaultValue={archive.participants ?? ""} className="field" />
|
||||
</div>
|
||||
<button type="submit" style={primaryButton}>Enregistrer</button>
|
||||
</form>
|
||||
|
||||
<div style={{ marginTop: 14, borderTop: "1px solid #f0e8d6", paddingTop: 14 }}>
|
||||
<form action={addPastMeetingPhoto} style={{ display: "grid", gap: 10 }}>
|
||||
<input type="hidden" name="pastMeetingId" value={archive.id} />
|
||||
<ImageField name="imageUrl" label="Ajouter une photo" height={100} />
|
||||
<input name="caption" className="field" placeholder="Légende" />
|
||||
<button type="submit" style={primaryButton}>Ajouter la photo</button>
|
||||
</form>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 8, marginTop: 12 }}>
|
||||
{(photosByPast.get(archive.id) ?? []).map((photo) => (
|
||||
<form key={photo.id} action={deletePastMeetingPhoto}>
|
||||
<input type="hidden" name="id" value={photo.id} />
|
||||
<button type="submit" title="Supprimer" style={{ width: "100%", aspectRatio: "1 / 1", border: "1px solid #e6dcc6", borderRadius: 8, cursor: "pointer", background: `center/cover no-repeat url(${photo.imageUrl})` }} />
|
||||
</form>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form action={deletePastMeeting} style={{ marginTop: 12 }}>
|
||||
<input type="hidden" name="id" value={archive.id} />
|
||||
<button type="submit" style={dangerButton}>Supprimer l'archive</button>
|
||||
</form>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
{rows.length === 0 && <div style={emptyStyle}>Aucune rencontre programmée.</div>}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const cardStyle: CSSProperties = {
|
||||
background: "#fff",
|
||||
border: "1px solid #e6dcc6",
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
};
|
||||
|
||||
const sectionTitle: CSSProperties = {
|
||||
fontSize: 12.5,
|
||||
letterSpacing: "0.08em",
|
||||
textTransform: "uppercase" as const,
|
||||
color: "#9a8d72",
|
||||
fontWeight: 800,
|
||||
marginBottom: 12,
|
||||
};
|
||||
|
||||
const primaryButton: CSSProperties = {
|
||||
border: "none",
|
||||
background: "#13324F",
|
||||
color: "#fff",
|
||||
fontWeight: 800,
|
||||
fontSize: 13.5,
|
||||
padding: "11px 16px",
|
||||
borderRadius: 10,
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
const dangerButton: CSSProperties = {
|
||||
width: "100%",
|
||||
border: "1px solid #e0c3bb",
|
||||
background: "#fff",
|
||||
color: "#d8472b",
|
||||
fontWeight: 800,
|
||||
fontSize: 13,
|
||||
padding: "10px 14px",
|
||||
borderRadius: 10,
|
||||
cursor: "pointer",
|
||||
};
|
||||
const panelStyle: CSSProperties = { background: "#fff", border: "1px solid #e6dcc6", borderRadius: 8, padding: 22 };
|
||||
const cardStyle: CSSProperties = { background: "#fff", border: "1px solid #e6dcc6", borderRadius: 8, padding: 16 };
|
||||
const sectionTitle: CSSProperties = { fontSize: 12.5, letterSpacing: "0.08em", textTransform: "uppercase", color: "#9a8d72", fontWeight: 800, marginBottom: 12 };
|
||||
const primaryButton: CSSProperties = { border: "none", background: "#13324F", color: "#fff", fontWeight: 800, fontSize: 13.5, padding: "11px 16px", borderRadius: 8, cursor: "pointer" };
|
||||
const dangerButton: CSSProperties = { width: "100%", border: "1px solid #e0c3bb", background: "#fff", color: "#d8472b", fontWeight: 800, fontSize: 13, padding: "10px 14px", borderRadius: 8, cursor: "pointer" };
|
||||
const emptyStyle: CSSProperties = { background: "#fff", border: "1px solid #e6dcc6", borderRadius: 8, padding: 24, color: "#8c8068" };
|
||||
Reference in new issue
Block a user