mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-11 17:27:54 +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
@@ -0,0 +1,11 @@
|
|||||||
|
DROP INDEX "meeting_registrations_meeting_member_idx";--> statement-breakpoint
|
||||||
|
ALTER TABLE "meeting_registrations" ADD COLUMN "attendee_company" varchar(200) DEFAULT '' NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "meeting_registrations" ADD COLUMN "attendee_phone" varchar(40);--> statement-breakpoint
|
||||||
|
ALTER TABLE "meeting_registrations" ADD COLUMN "status" varchar(20) DEFAULT 'pending' NOT NULL;--> statement-breakpoint
|
||||||
|
UPDATE "meeting_registrations" AS r
|
||||||
|
SET "attendee_company" = m."name", "attendee_phone" = m."phone", "status" = 'confirmed'
|
||||||
|
FROM "members" AS m
|
||||||
|
WHERE r."member_id" = m."id";--> statement-breakpoint
|
||||||
|
ALTER TABLE "meetings" ADD COLUMN "participants_per_account" integer DEFAULT 1 NOT NULL;--> statement-breakpoint
|
||||||
|
CREATE INDEX "meeting_registrations_meeting_idx" ON "meeting_registrations" USING btree ("meeting_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "meeting_registrations_member_meeting_idx" ON "meeting_registrations" USING btree ("member_id","meeting_id");
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -36,6 +36,13 @@
|
|||||||
"when": 1784303079565,
|
"when": 1784303079565,
|
||||||
"tag": "0004_tiny_vanisher",
|
"tag": "0004_tiny_vanisher",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 5,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1784339279276,
|
||||||
|
"tag": "0005_round_exiles",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
+13
-112
@@ -1,18 +1,11 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { and, asc, desc, eq, gte, inArray, sql } from "drizzle-orm";
|
import { and, asc, eq, gte, inArray, sql } from "drizzle-orm";
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import {
|
import { meetingRegistrations, meetings } from "@/db/schema";
|
||||||
meetingRegistrations,
|
|
||||||
meetings,
|
|
||||||
pastMeetingPhotos,
|
|
||||||
pastMeetings,
|
|
||||||
} from "@/db/schema";
|
|
||||||
import { PastMeetingGallery } from "@/components/PastMeetingGallery";
|
|
||||||
import { SiteFooter } from "@/components/SiteFooter";
|
import { SiteFooter } from "@/components/SiteFooter";
|
||||||
import { SiteHeader } from "@/components/SiteHeader";
|
import { SiteHeader } from "@/components/SiteHeader";
|
||||||
import { getSiteSettings, parseBoardMembers, splitLines } from "@/lib/site-settings";
|
import { getSiteSettings, parseBoardMembers } from "@/lib/site-settings";
|
||||||
import { cancelMeetingRegistration, registerForMeeting } from "../backend/actions";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -42,6 +35,7 @@ export default async function AssociationPage() {
|
|||||||
location: meetings.location,
|
location: meetings.location,
|
||||||
description: meetings.description,
|
description: meetings.description,
|
||||||
capacity: meetings.capacity,
|
capacity: meetings.capacity,
|
||||||
|
participantsPerAccount: meetings.participantsPerAccount,
|
||||||
imageUrl: meetings.imageUrl,
|
imageUrl: meetings.imageUrl,
|
||||||
registered: sql<number>`count(${meetingRegistrations.id})`,
|
registered: sql<number>`count(${meetingRegistrations.id})`,
|
||||||
})
|
})
|
||||||
@@ -51,29 +45,6 @@ export default async function AssociationPage() {
|
|||||||
.groupBy(meetings.id)
|
.groupBy(meetings.id)
|
||||||
.orderBy(asc(meetings.startsAt));
|
.orderBy(asc(meetings.startsAt));
|
||||||
|
|
||||||
const past = await db
|
|
||||||
.select()
|
|
||||||
.from(pastMeetings)
|
|
||||||
.orderBy(desc(pastMeetings.eventDate));
|
|
||||||
|
|
||||||
const photoRows = await db
|
|
||||||
.select()
|
|
||||||
.from(pastMeetingPhotos)
|
|
||||||
.orderBy(asc(pastMeetingPhotos.position), asc(pastMeetingPhotos.id));
|
|
||||||
|
|
||||||
const meetingIds = past.map((item) => item.meetingId).filter((id): id is number => id != null);
|
|
||||||
const linkedRegistrations =
|
|
||||||
meetingIds.length > 0
|
|
||||||
? await db
|
|
||||||
.select({
|
|
||||||
meetingId: meetingRegistrations.meetingId,
|
|
||||||
attendeeName: meetingRegistrations.attendeeName,
|
|
||||||
})
|
|
||||||
.from(meetingRegistrations)
|
|
||||||
.where(inArray(meetingRegistrations.meetingId, meetingIds))
|
|
||||||
.orderBy(asc(meetingRegistrations.attendeeName))
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const myRegistrations =
|
const myRegistrations =
|
||||||
memberId && upcoming.length > 0
|
memberId && upcoming.length > 0
|
||||||
? await db
|
? await db
|
||||||
@@ -91,18 +62,6 @@ export default async function AssociationPage() {
|
|||||||
: [];
|
: [];
|
||||||
const myMeetingIds = new Set(myRegistrations.map((row) => row.meetingId));
|
const myMeetingIds = new Set(myRegistrations.map((row) => row.meetingId));
|
||||||
|
|
||||||
const photosByPast = new Map<number, typeof photoRows>();
|
|
||||||
for (const photo of photoRows) {
|
|
||||||
photosByPast.set(photo.pastMeetingId, [...(photosByPast.get(photo.pastMeetingId) ?? []), photo]);
|
|
||||||
}
|
|
||||||
const registrationsByMeeting = new Map<number, string[]>();
|
|
||||||
for (const reg of linkedRegistrations) {
|
|
||||||
registrationsByMeeting.set(reg.meetingId, [
|
|
||||||
...(registrationsByMeeting.get(reg.meetingId) ?? []),
|
|
||||||
reg.attendeeName,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const officers = [
|
const officers = [
|
||||||
["Vice-présidence", settings.association_vice_president],
|
["Vice-présidence", settings.association_vice_president],
|
||||||
["Trésorerie", settings.association_treasurer],
|
["Trésorerie", settings.association_treasurer],
|
||||||
@@ -191,31 +150,15 @@ export default async function AssociationPage() {
|
|||||||
<span>{registered}/{meeting.capacity} inscrits</span>
|
<span>{registered}/{meeting.capacity} inscrits</span>
|
||||||
<span>{remaining} place(s)</span>
|
<span>{remaining} place(s)</span>
|
||||||
</div>
|
</div>
|
||||||
{memberId ? (
|
<div style={{ color: "#8c8068", fontSize: 12.5, marginBottom: 10 }}>
|
||||||
isRegistered ? (
|
Jusqu'à {meeting.participantsPerAccount} participant{meeting.participantsPerAccount > 1 ? "s" : ""} par compte
|
||||||
<form action={cancelMeetingRegistration}>
|
</div>
|
||||||
<input type="hidden" name="meetingId" value={meeting.id} />
|
<Link
|
||||||
<button type="submit" style={{ width: "100%", border: "1px solid #e0c3bb", background: "#fff", color: "#d8472b", borderRadius: 10, padding: 11, fontWeight: 800, cursor: "pointer" }}>
|
href={`/inscription/${meeting.id}`}
|
||||||
Annuler mon inscription
|
style={{ display: "block", textAlign: "center", textDecoration: "none", background: remaining <= 0 && !isRegistered ? "#a99c82" : "#13324F", color: "#fff", borderRadius: 8, padding: 11, fontWeight: 800 }}
|
||||||
</button>
|
>
|
||||||
</form>
|
{isRegistered ? "Modifier mon inscription" : remaining <= 0 ? "Complet" : memberId ? "S'inscrire" : "Connexion et inscription"}
|
||||||
) : (
|
</Link>
|
||||||
<form action={registerForMeeting}>
|
|
||||||
<input type="hidden" name="meetingId" value={meeting.id} />
|
|
||||||
<label style={{ display: "flex", gap: 8, alignItems: "start", fontSize: 12.5, color: "#6c6150", marginBottom: 10 }}>
|
|
||||||
<input type="checkbox" name="imageConsent" />
|
|
||||||
J'accepte d'apparaître sur les photos de cette rencontre.
|
|
||||||
</label>
|
|
||||||
<button type="submit" disabled={remaining <= 0} style={{ width: "100%", border: "none", background: remaining <= 0 ? "#a99c82" : "#13324F", color: "#fff", borderRadius: 10, padding: 11, fontWeight: 800, cursor: remaining <= 0 ? "not-allowed" : "pointer" }}>
|
|
||||||
{remaining <= 0 ? "Complet" : "Je m'inscris"}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<Link href="/login" style={{ display: "block", textAlign: "center", textDecoration: "none", background: "#13324F", color: "#fff", borderRadius: 10, padding: 11, fontWeight: 800 }}>
|
|
||||||
Connexion adhérent
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
@@ -223,48 +166,6 @@ export default async function AssociationPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section style={{ background: "#EFE9DA" }}>
|
|
||||||
<div className="container" style={{ paddingTop: 54, paddingBottom: 54 }}>
|
|
||||||
<div style={{ color: "#9a6638", fontSize: 12, letterSpacing: "0.14em", textTransform: "uppercase", fontWeight: 800 }}>
|
|
||||||
Archives
|
|
||||||
</div>
|
|
||||||
<h2 className="font-display" style={{ margin: "8px 0 20px", color: "#26201a", fontSize: 34 }}>
|
|
||||||
Rencontres passées
|
|
||||||
</h2>
|
|
||||||
{past.length === 0 && (
|
|
||||||
<div style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 14, padding: 24, color: "#8c8068" }}>
|
|
||||||
Aucune rencontre passée publiée pour le moment.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="grid grid-3" style={{ gap: 18 }}>
|
|
||||||
{past.map((item) => {
|
|
||||||
const linkedNames = item.meetingId ? registrationsByMeeting.get(item.meetingId) ?? [] : [];
|
|
||||||
const manualNames = splitLines(item.participants ?? "");
|
|
||||||
const names = linkedNames.length > 0 ? linkedNames : manualNames;
|
|
||||||
return (
|
|
||||||
<article key={item.id} style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 12, padding: 18 }}>
|
|
||||||
<div style={{ color: "#9a6638", fontSize: 12.5, fontWeight: 800 }}>
|
|
||||||
{formatDate(item.eventDate)}
|
|
||||||
</div>
|
|
||||||
<h3 className="font-display" style={{ color: "#26201a", fontSize: 21, margin: "7px 0" }}>
|
|
||||||
{item.title}
|
|
||||||
</h3>
|
|
||||||
{item.location && <div style={{ fontSize: 13, color: "#6c6150", fontWeight: 700 }}>{item.location}</div>}
|
|
||||||
<p style={{ color: "#8c8068", fontSize: 13.5, lineHeight: 1.6 }}>{item.description}</p>
|
|
||||||
{names.length > 0 && (
|
|
||||||
<div style={{ color: "#6c6150", fontSize: 12.5, lineHeight: 1.6 }}>
|
|
||||||
<strong>Participants:</strong> {names.slice(0, 6).join(", ")}
|
|
||||||
{names.length > 6 ? ` +${names.length - 6}` : ""}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<PastMeetingGallery title={item.title} photos={photosByPast.get(item.id) ?? []} />
|
|
||||||
</article>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="container" style={{ paddingTop: 54, paddingBottom: 64 }}>
|
<section className="container" style={{ paddingTop: 54, paddingBottom: 64 }}>
|
||||||
<div style={{ color: "#9a6638", fontSize: 12, letterSpacing: "0.14em", textTransform: "uppercase", fontWeight: 800 }}>
|
<div style={{ color: "#9a6638", fontSize: 12, letterSpacing: "0.14em", textTransform: "uppercase", fontWeight: 800 }}>
|
||||||
Gouvernance
|
Gouvernance
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ const TITLES: Record<string, [string, string]> = {
|
|||||||
"Modération des promotions",
|
"Modération des promotions",
|
||||||
"Validez les offres soumises par les adhérents",
|
"Validez les offres soumises par les adhérents",
|
||||||
],
|
],
|
||||||
"/backend/rencontres": ["Rencontres", "Gérer les prochaines dates et les archives"],
|
"/backend/rencontres": ["Rencontres", "Gérer les prochaines dates"],
|
||||||
|
"/backend/inscriptions": ["Inscriptions", "Gérer les participants aux rencontres"],
|
||||||
|
"/backend/rencontres-passees": ["Rencontres passées", "Publier les archives et leurs photos"],
|
||||||
"/backend/emails": ["Création d'e-mails", "Composer des messages aux couleurs de Plein R"],
|
"/backend/emails": ["Création d'e-mails", "Composer des messages aux couleurs de Plein R"],
|
||||||
"/backend/administrateurs": ["Administrateurs", "Gérer les accès à l'administration"],
|
"/backend/administrateurs": ["Administrateurs", "Gérer les accès à l'administration"],
|
||||||
"/backend/categories": ["Catégories", "Gérer les métiers de l'annuaire"],
|
"/backend/categories": ["Catégories", "Gérer les métiers de l'annuaire"],
|
||||||
@@ -149,6 +151,16 @@ export function BackendShell({
|
|||||||
{dotRound}Rencontres
|
{dotRound}Rencontres
|
||||||
</NavLink>
|
</NavLink>
|
||||||
)}
|
)}
|
||||||
|
{can(user.role, "manageMeetings") && (
|
||||||
|
<NavLink href="/backend/inscriptions" active={pathname === "/backend/inscriptions"}>
|
||||||
|
{dot}Inscriptions
|
||||||
|
</NavLink>
|
||||||
|
)}
|
||||||
|
{can(user.role, "manageMeetings") && (
|
||||||
|
<NavLink href="/backend/rencontres-passees" active={pathname === "/backend/rencontres-passees"}>
|
||||||
|
{dotDiamond}Rencontres passées
|
||||||
|
</NavLink>
|
||||||
|
)}
|
||||||
{can(user.role, "manageEmails") && (
|
{can(user.role, "manageEmails") && (
|
||||||
<NavLink href="/backend/emails" active={pathname === "/backend/emails"}>
|
<NavLink href="/backend/emails" active={pathname === "/backend/emails"}>
|
||||||
{dot}E-mails
|
{dot}E-mails
|
||||||
|
|||||||
+164
-45
@@ -406,11 +406,13 @@ export async function createMeeting(formData: FormData) {
|
|||||||
startsAt: asDate(formData, "startsAt"),
|
startsAt: asDate(formData, "startsAt"),
|
||||||
location: asNullableString(formData, "location"),
|
location: asNullableString(formData, "location"),
|
||||||
description: asNullableString(formData, "description"),
|
description: asNullableString(formData, "description"),
|
||||||
capacity: Number(formData.get("capacity") ?? 30) || 30,
|
capacity: Math.min(100000, Math.max(1, Number(formData.get("capacity") ?? 30) || 30)),
|
||||||
|
participantsPerAccount: Math.min(100, Math.max(1, Number(formData.get("participantsPerAccount") ?? 1) || 1)),
|
||||||
imageUrl: asNullableString(formData, "imageUrl"),
|
imageUrl: asNullableString(formData, "imageUrl"),
|
||||||
});
|
});
|
||||||
await logActivity(`Rencontre ajoutée : <strong>${title}</strong>`, "#2C6FB3");
|
await logActivity(`Rencontre ajoutée : <strong>${title}</strong>`, "#2C6FB3");
|
||||||
revalidatePath("/backend/rencontres");
|
revalidatePath("/backend/rencontres");
|
||||||
|
revalidatePath("/backend/inscriptions");
|
||||||
revalidatePath("/association");
|
revalidatePath("/association");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -428,11 +430,13 @@ export async function updateMeeting(formData: FormData) {
|
|||||||
startsAt: asDate(formData, "startsAt"),
|
startsAt: asDate(formData, "startsAt"),
|
||||||
location: asNullableString(formData, "location"),
|
location: asNullableString(formData, "location"),
|
||||||
description: asNullableString(formData, "description"),
|
description: asNullableString(formData, "description"),
|
||||||
capacity: Number(formData.get("capacity") ?? 30) || 30,
|
capacity: Math.min(100000, Math.max(1, Number(formData.get("capacity") ?? 30) || 30)),
|
||||||
|
participantsPerAccount: Math.min(100, Math.max(1, Number(formData.get("participantsPerAccount") ?? 1) || 1)),
|
||||||
imageUrl: asNullableString(formData, "imageUrl"),
|
imageUrl: asNullableString(formData, "imageUrl"),
|
||||||
})
|
})
|
||||||
.where(eq(meetings.id, id));
|
.where(eq(meetings.id, id));
|
||||||
revalidatePath("/backend/rencontres");
|
revalidatePath("/backend/rencontres");
|
||||||
|
revalidatePath("/backend/inscriptions");
|
||||||
revalidatePath("/association");
|
revalidatePath("/association");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -443,65 +447,170 @@ export async function deleteMeeting(formData: FormData) {
|
|||||||
if (!id) return;
|
if (!id) return;
|
||||||
await db.delete(meetings).where(eq(meetings.id, id));
|
await db.delete(meetings).where(eq(meetings.id, id));
|
||||||
revalidatePath("/backend/rencontres");
|
revalidatePath("/backend/rencontres");
|
||||||
|
revalidatePath("/backend/inscriptions");
|
||||||
revalidatePath("/association");
|
revalidatePath("/association");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function registerForMeeting(formData: FormData) {
|
export type MeetingRegistrationState = {
|
||||||
|
status: "idle" | "success" | "error";
|
||||||
|
message: string;
|
||||||
|
registrationCount?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function saveMeetingRegistration(
|
||||||
|
_previousState: MeetingRegistrationState,
|
||||||
|
formData: FormData,
|
||||||
|
): Promise<MeetingRegistrationState> {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user?.memberId) throw new Error("Compte adhérent requis");
|
if (!session?.user?.memberId) {
|
||||||
|
return { status: "error", message: "Connectez-vous avec un compte adhérent pour vous inscrire." };
|
||||||
|
}
|
||||||
const memberId = session.user.memberId;
|
const memberId = session.user.memberId;
|
||||||
const meetingId = Number(formData.get("meetingId"));
|
const meetingId = Number(formData.get("meetingId"));
|
||||||
if (!meetingId) return;
|
if (!meetingId) return { status: "error", message: "Rencontre introuvable." };
|
||||||
|
|
||||||
await db.transaction(async (tx) => {
|
try {
|
||||||
const [meeting] = await tx.select().from(meetings).where(eq(meetings.id, meetingId));
|
if (asString(formData, "intent") === "cancel") {
|
||||||
if (!meeting) throw new Error("Rencontre introuvable");
|
await db
|
||||||
if (meeting.startsAt.getTime() < Date.now()) throw new Error("Cette rencontre est passée");
|
.delete(meetingRegistrations)
|
||||||
|
.where(and(eq(meetingRegistrations.meetingId, meetingId), eq(meetingRegistrations.memberId, memberId)));
|
||||||
|
revalidateMeetingPaths(meetingId);
|
||||||
|
return { status: "success", message: "Votre inscription a été annulée.", registrationCount: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
const duplicate = await tx
|
const participantCount = Number(formData.get("participantCount"));
|
||||||
.select({ id: meetingRegistrations.id })
|
if (!Number.isInteger(participantCount) || participantCount < 1 || participantCount > 100) {
|
||||||
.from(meetingRegistrations)
|
return { status: "error", message: "Ajoutez au moins un participant." };
|
||||||
.where(and(eq(meetingRegistrations.meetingId, meetingId), eq(meetingRegistrations.memberId, memberId)));
|
}
|
||||||
if (duplicate.length > 0) return;
|
|
||||||
|
|
||||||
const [{ total } = { total: 0 }] = await tx
|
const participants = Array.from({ length: participantCount }, (_, index) => ({
|
||||||
.select({ total: sql<number>`count(*)` })
|
name: asString(formData, `participantName-${index}`),
|
||||||
.from(meetingRegistrations)
|
imageConsent: formData.get(`imageConsent-${index}`) === "on",
|
||||||
.where(eq(meetingRegistrations.meetingId, meetingId));
|
}));
|
||||||
if (Number(total) >= meeting.capacity) throw new Error("Rencontre complète");
|
if (participants.some((participant) => !participant.name || participant.name.length > 200)) {
|
||||||
|
return { status: "error", message: "Renseignez le nom et le prénom de chaque participant." };
|
||||||
|
}
|
||||||
|
const normalizedNames = participants.map((participant) => participant.name.toLocaleLowerCase("fr-FR"));
|
||||||
|
if (new Set(normalizedNames).size !== normalizedNames.length) {
|
||||||
|
return { status: "error", message: "Chaque participant doit avoir un nom différent." };
|
||||||
|
}
|
||||||
|
if (formData.get("attestation") !== "on") {
|
||||||
|
return { status: "error", message: "Vous devez confirmer avoir recueilli le choix de chaque participant." };
|
||||||
|
}
|
||||||
|
|
||||||
const [member] = await tx
|
await db.transaction(async (tx) => {
|
||||||
.select({ name: members.name, email: members.email })
|
await tx.execute(sql`select ${meetings.id} from ${meetings} where ${meetings.id} = ${meetingId} for update`);
|
||||||
.from(members)
|
const [meeting] = await tx.select().from(meetings).where(eq(meetings.id, meetingId));
|
||||||
.where(eq(members.id, memberId));
|
if (!meeting) throw new Error("Rencontre introuvable.");
|
||||||
await tx.insert(meetingRegistrations).values({
|
if (meeting.startsAt.getTime() < Date.now()) throw new Error("Cette rencontre est passée.");
|
||||||
meetingId,
|
if (participants.length > meeting.participantsPerAccount) {
|
||||||
memberId,
|
throw new Error(`Cette rencontre autorise ${meeting.participantsPerAccount} participant(s) maximum par compte.`);
|
||||||
attendeeName: member?.name ?? session.user.name ?? "Adhérent",
|
}
|
||||||
attendeeEmail: member?.email ?? session.user.email ?? null,
|
|
||||||
imageConsent: formData.get("imageConsent") === "on",
|
const [member] = await tx
|
||||||
|
.select({ name: members.name, email: members.email, phone: members.phone, status: members.status })
|
||||||
|
.from(members)
|
||||||
|
.where(eq(members.id, memberId));
|
||||||
|
if (!member || member.status !== "active") {
|
||||||
|
throw new Error("Votre adhésion doit être active pour vous inscrire.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const [{ total } = { total: 0 }] = await tx
|
||||||
|
.select({ total: sql<number>`count(*)` })
|
||||||
|
.from(meetingRegistrations)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(meetingRegistrations.meetingId, meetingId),
|
||||||
|
sql`${meetingRegistrations.memberId} is distinct from ${memberId}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (Number(total) + participants.length > meeting.capacity) {
|
||||||
|
throw new Error("Il ne reste pas assez de places pour tous les participants.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx
|
||||||
|
.delete(meetingRegistrations)
|
||||||
|
.where(and(eq(meetingRegistrations.meetingId, meetingId), eq(meetingRegistrations.memberId, memberId)));
|
||||||
|
await tx.insert(meetingRegistrations).values(
|
||||||
|
participants.map((participant) => ({
|
||||||
|
meetingId,
|
||||||
|
memberId,
|
||||||
|
attendeeName: participant.name,
|
||||||
|
attendeeCompany: member.name,
|
||||||
|
attendeeEmail: member.email ?? session.user.email ?? null,
|
||||||
|
attendeePhone: member.phone,
|
||||||
|
status: "pending",
|
||||||
|
imageConsent: participant.imageConsent,
|
||||||
|
})),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
revalidatePath("/association");
|
revalidateMeetingPaths(meetingId);
|
||||||
revalidatePath("/backend/espace");
|
return {
|
||||||
|
status: "success",
|
||||||
|
message: `Inscription enregistrée pour ${participants.length} participant${participants.length > 1 ? "s" : ""}.`,
|
||||||
|
registrationCount: participants.length,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return { status: "error", message: error instanceof Error ? error.message : "Inscription impossible." };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cancelMeetingRegistration(formData: FormData) {
|
function revalidateMeetingPaths(meetingId?: number) {
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user?.memberId) throw new Error("Compte adhérent requis");
|
|
||||||
const meetingId = Number(formData.get("meetingId"));
|
|
||||||
if (!meetingId) return;
|
|
||||||
await db
|
|
||||||
.delete(meetingRegistrations)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(meetingRegistrations.meetingId, meetingId),
|
|
||||||
eq(meetingRegistrations.memberId, session.user.memberId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
revalidatePath("/association");
|
revalidatePath("/association");
|
||||||
|
revalidatePath("/backend/rencontres");
|
||||||
|
revalidatePath("/backend/inscriptions");
|
||||||
|
revalidatePath("/backend/rencontres-passees");
|
||||||
revalidatePath("/backend/espace");
|
revalidatePath("/backend/espace");
|
||||||
|
revalidatePath("/rencontres-passees");
|
||||||
|
if (meetingId) revalidatePath(`/inscription/${meetingId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateMeetingRegistration(formData: FormData) {
|
||||||
|
const { role } = await requireRole();
|
||||||
|
if (!can(role, "manageMeetings")) throw new Error("Accès refusé");
|
||||||
|
const id = Number(formData.get("id"));
|
||||||
|
const attendeeName = asString(formData, "attendeeName");
|
||||||
|
const status = asString(formData, "status");
|
||||||
|
if (!id || !attendeeName || !["pending", "confirmed"].includes(status)) return;
|
||||||
|
const [registration] = await db
|
||||||
|
.update(meetingRegistrations)
|
||||||
|
.set({
|
||||||
|
attendeeName,
|
||||||
|
attendeeCompany: asString(formData, "attendeeCompany"),
|
||||||
|
attendeeEmail: asNullableString(formData, "attendeeEmail"),
|
||||||
|
attendeePhone: asNullableString(formData, "attendeePhone"),
|
||||||
|
status,
|
||||||
|
imageConsent: formData.get("imageConsent") === "on",
|
||||||
|
})
|
||||||
|
.where(eq(meetingRegistrations.id, id))
|
||||||
|
.returning({ meetingId: meetingRegistrations.meetingId });
|
||||||
|
revalidateMeetingPaths(registration?.meetingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function confirmMeetingRegistration(formData: FormData) {
|
||||||
|
const { role } = await requireRole();
|
||||||
|
if (!can(role, "manageMeetings")) throw new Error("Accès refusé");
|
||||||
|
const id = Number(formData.get("id"));
|
||||||
|
if (!id) return;
|
||||||
|
const [registration] = await db
|
||||||
|
.update(meetingRegistrations)
|
||||||
|
.set({ status: "confirmed" })
|
||||||
|
.where(eq(meetingRegistrations.id, id))
|
||||||
|
.returning({ meetingId: meetingRegistrations.meetingId });
|
||||||
|
revalidateMeetingPaths(registration?.meetingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteMeetingRegistration(formData: FormData) {
|
||||||
|
const { role } = await requireRole();
|
||||||
|
if (!can(role, "manageMeetings")) throw new Error("Accès refusé");
|
||||||
|
const id = Number(formData.get("id"));
|
||||||
|
if (!id) return;
|
||||||
|
const [registration] = await db
|
||||||
|
.delete(meetingRegistrations)
|
||||||
|
.where(eq(meetingRegistrations.id, id))
|
||||||
|
.returning({ meetingId: meetingRegistrations.meetingId });
|
||||||
|
revalidateMeetingPaths(registration?.meetingId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createPastMeeting(formData: FormData) {
|
export async function createPastMeeting(formData: FormData) {
|
||||||
@@ -519,6 +628,8 @@ export async function createPastMeeting(formData: FormData) {
|
|||||||
});
|
});
|
||||||
revalidatePath("/backend/rencontres");
|
revalidatePath("/backend/rencontres");
|
||||||
revalidatePath("/association");
|
revalidatePath("/association");
|
||||||
|
revalidatePath("/backend/rencontres-passees");
|
||||||
|
revalidatePath("/rencontres-passees");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updatePastMeeting(formData: FormData) {
|
export async function updatePastMeeting(formData: FormData) {
|
||||||
@@ -540,6 +651,8 @@ export async function updatePastMeeting(formData: FormData) {
|
|||||||
.where(eq(pastMeetings.id, id));
|
.where(eq(pastMeetings.id, id));
|
||||||
revalidatePath("/backend/rencontres");
|
revalidatePath("/backend/rencontres");
|
||||||
revalidatePath("/association");
|
revalidatePath("/association");
|
||||||
|
revalidatePath("/backend/rencontres-passees");
|
||||||
|
revalidatePath("/rencontres-passees");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deletePastMeeting(formData: FormData) {
|
export async function deletePastMeeting(formData: FormData) {
|
||||||
@@ -550,6 +663,8 @@ export async function deletePastMeeting(formData: FormData) {
|
|||||||
await db.delete(pastMeetings).where(eq(pastMeetings.id, id));
|
await db.delete(pastMeetings).where(eq(pastMeetings.id, id));
|
||||||
revalidatePath("/backend/rencontres");
|
revalidatePath("/backend/rencontres");
|
||||||
revalidatePath("/association");
|
revalidatePath("/association");
|
||||||
|
revalidatePath("/backend/rencontres-passees");
|
||||||
|
revalidatePath("/rencontres-passees");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addPastMeetingPhoto(formData: FormData) {
|
export async function addPastMeetingPhoto(formData: FormData) {
|
||||||
@@ -570,6 +685,8 @@ export async function addPastMeetingPhoto(formData: FormData) {
|
|||||||
});
|
});
|
||||||
revalidatePath("/backend/rencontres");
|
revalidatePath("/backend/rencontres");
|
||||||
revalidatePath("/association");
|
revalidatePath("/association");
|
||||||
|
revalidatePath("/backend/rencontres-passees");
|
||||||
|
revalidatePath("/rencontres-passees");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deletePastMeetingPhoto(formData: FormData) {
|
export async function deletePastMeetingPhoto(formData: FormData) {
|
||||||
@@ -580,6 +697,8 @@ export async function deletePastMeetingPhoto(formData: FormData) {
|
|||||||
await db.delete(pastMeetingPhotos).where(eq(pastMeetingPhotos.id, id));
|
await db.delete(pastMeetingPhotos).where(eq(pastMeetingPhotos.id, id));
|
||||||
revalidatePath("/backend/rencontres");
|
revalidatePath("/backend/rencontres");
|
||||||
revalidatePath("/association");
|
revalidatePath("/association");
|
||||||
|
revalidatePath("/backend/rencontres-passees");
|
||||||
|
revalidatePath("/rencontres-passees");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Paramètres du site ----
|
// ---- Paramètres du site ----
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { asc, desc, eq } from "drizzle-orm";
|
import Link from "next/link";
|
||||||
|
import { and, asc, desc, eq, gte, sql } from "drizzle-orm";
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { categories, imageConsents, members, promotions, type Member } from "@/db/schema";
|
import { categories, imageConsents, meetingRegistrations, meetings, members, promotions, type Member } from "@/db/schema";
|
||||||
import { ImageField } from "@/components/ImageField";
|
import { ImageField } from "@/components/ImageField";
|
||||||
import { ImageConsentForm } from "@/components/ImageConsentForm";
|
import { ImageConsentForm } from "@/components/ImageConsentForm";
|
||||||
import { communeOptions } from "@/lib/communes";
|
import { communeOptions } from "@/lib/communes";
|
||||||
@@ -30,6 +31,7 @@ export default async function EspacePage() {
|
|||||||
let subtitle = "Espace adhérent";
|
let subtitle = "Espace adhérent";
|
||||||
let profile: Member | null = null;
|
let profile: Member | null = null;
|
||||||
let myPromos: { id: number; title: string; status: string; createdAt: Date; imageUrl: string | null }[] = [];
|
let myPromos: { id: number; title: string; status: string; createdAt: Date; imageUrl: string | null }[] = [];
|
||||||
|
let myRegistrations: { meetingId: number; title: string; startsAt: Date; location: string | null; participants: number; confirmed: boolean }[] = [];
|
||||||
let latestConsent: { decision: string; createdAt: Date } | null = null;
|
let latestConsent: { decision: string; createdAt: Date } | null = null;
|
||||||
|
|
||||||
if (memberId) {
|
if (memberId) {
|
||||||
@@ -53,6 +55,20 @@ export default async function EspacePage() {
|
|||||||
.from(promotions)
|
.from(promotions)
|
||||||
.where(eq(promotions.memberId, memberId))
|
.where(eq(promotions.memberId, memberId))
|
||||||
.orderBy(desc(promotions.createdAt));
|
.orderBy(desc(promotions.createdAt));
|
||||||
|
myRegistrations = await db
|
||||||
|
.select({
|
||||||
|
meetingId: meetings.id,
|
||||||
|
title: meetings.title,
|
||||||
|
startsAt: meetings.startsAt,
|
||||||
|
location: meetings.location,
|
||||||
|
participants: sql<number>`count(${meetingRegistrations.id})`,
|
||||||
|
confirmed: sql<boolean>`bool_and(${meetingRegistrations.status} = 'confirmed')`,
|
||||||
|
})
|
||||||
|
.from(meetingRegistrations)
|
||||||
|
.innerJoin(meetings, eq(meetings.id, meetingRegistrations.meetingId))
|
||||||
|
.where(and(eq(meetingRegistrations.memberId, memberId), gte(meetings.startsAt, new Date())))
|
||||||
|
.groupBy(meetings.id)
|
||||||
|
.orderBy(asc(meetings.startsAt));
|
||||||
const [consent] = await db
|
const [consent] = await db
|
||||||
.select({ decision: imageConsents.decision, createdAt: imageConsents.createdAt })
|
.select({ decision: imageConsents.decision, createdAt: imageConsents.createdAt })
|
||||||
.from(imageConsents)
|
.from(imageConsents)
|
||||||
@@ -93,6 +109,37 @@ export default async function EspacePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{memberId && (
|
||||||
|
<section style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 8, padding: 24, marginBottom: 28 }}>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", gap: 12, alignItems: "end", marginBottom: 15, flexWrap: "wrap" }}>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 18, margin: 0, color: "#26201a" }}>Mes inscriptions</h3>
|
||||||
|
<div style={{ color: "#8c8068", fontSize: 13, marginTop: 4 }}>Participants inscrits aux prochaines rencontres.</div>
|
||||||
|
</div>
|
||||||
|
<Link href="/association" style={{ color: "#9a6638", fontSize: 13, fontWeight: 800, textDecoration: "none" }}>Voir les rencontres</Link>
|
||||||
|
</div>
|
||||||
|
{myRegistrations.length === 0 && <div style={{ color: "#a99c82", fontSize: 13.5 }}>Aucune inscription à venir.</div>}
|
||||||
|
<div style={{ display: "grid", gap: 10 }}>
|
||||||
|
{myRegistrations.map((registration) => (
|
||||||
|
<div key={registration.meetingId} style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 14, borderTop: "1px solid #f0e8d6", paddingTop: 12, flexWrap: "wrap" }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ color: "#26201a", fontSize: 14, fontWeight: 800 }}>{registration.title}</div>
|
||||||
|
<div style={{ color: "#8c8068", fontSize: 12.5, marginTop: 3 }}>
|
||||||
|
{fmtDate(registration.startsAt)} · {registration.participants} participant{registration.participants > 1 ? "s" : ""}{registration.location ? ` · ${registration.location}` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||||
|
<span style={{ background: registration.confirmed ? "#e6f4ec" : "#fbeede", color: registration.confirmed ? "#1f8a5b" : "#9a6638", borderRadius: 999, padding: "5px 10px", fontSize: 11.5, fontWeight: 800 }}>
|
||||||
|
{registration.confirmed ? "Confirmée" : "En attente"}
|
||||||
|
</span>
|
||||||
|
<Link href={`/inscription/${registration.meetingId}`} style={{ color: "#9a6638", fontSize: 12.5, fontWeight: 800, textDecoration: "none" }}>Modifier</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{profile && (
|
{profile && (
|
||||||
<form action={updateOwnProfile} style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 16, padding: 24, marginBottom: 28 }}>
|
<form action={updateOwnProfile} style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 16, padding: 24, marginBottom: 28 }}>
|
||||||
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 18, margin: "0 0 18px", color: "#26201a" }}>
|
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 18, margin: "0 0 18px", color: "#26201a" }}>
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import type { CSSProperties } from "react";
|
||||||
|
import { and, desc, eq } from "drizzle-orm";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/db";
|
||||||
|
import { meetingRegistrations, meetings } from "@/db/schema";
|
||||||
|
import { can } from "@/lib/rbac";
|
||||||
|
import {
|
||||||
|
confirmMeetingRegistration,
|
||||||
|
deleteMeetingRegistration,
|
||||||
|
updateMeetingRegistration,
|
||||||
|
} from "../actions";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
function formatDate(date: Date) {
|
||||||
|
return new Intl.DateTimeFormat("fr-FR", { day: "numeric", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit" }).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function InscriptionsPage({ searchParams }: { searchParams: Promise<{ meeting?: string }> }) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!can(session?.user.role, "manageMeetings")) redirect("/backend");
|
||||||
|
const { meeting: meetingParam } = await searchParams;
|
||||||
|
const selectedMeetingId = Number(meetingParam) || null;
|
||||||
|
|
||||||
|
const [meetingRows, registrationRows] = await Promise.all([
|
||||||
|
db.select({ id: meetings.id, title: meetings.title, startsAt: meetings.startsAt }).from(meetings).orderBy(desc(meetings.startsAt)),
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
id: meetingRegistrations.id,
|
||||||
|
meetingId: meetingRegistrations.meetingId,
|
||||||
|
meetingTitle: meetings.title,
|
||||||
|
meetingStartsAt: meetings.startsAt,
|
||||||
|
attendeeName: meetingRegistrations.attendeeName,
|
||||||
|
attendeeCompany: meetingRegistrations.attendeeCompany,
|
||||||
|
attendeeEmail: meetingRegistrations.attendeeEmail,
|
||||||
|
attendeePhone: meetingRegistrations.attendeePhone,
|
||||||
|
status: meetingRegistrations.status,
|
||||||
|
imageConsent: meetingRegistrations.imageConsent,
|
||||||
|
createdAt: meetingRegistrations.createdAt,
|
||||||
|
})
|
||||||
|
.from(meetingRegistrations)
|
||||||
|
.innerJoin(meetings, eq(meetings.id, meetingRegistrations.meetingId))
|
||||||
|
.where(selectedMeetingId ? and(eq(meetingRegistrations.meetingId, selectedMeetingId)) : undefined)
|
||||||
|
.orderBy(desc(meetings.startsAt), desc(meetingRegistrations.createdAt)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: "grid", gap: 22 }}>
|
||||||
|
<section style={{ display: "flex", justifyContent: "space-between", alignItems: "end", gap: 16, flexWrap: "wrap" }}>
|
||||||
|
<div>
|
||||||
|
<div style={sectionLabel}>Participants</div>
|
||||||
|
<div style={{ color: "#6c6150", fontSize: 13.5, marginTop: 5 }}>{registrationRows.length} inscription{registrationRows.length > 1 ? "s" : ""} affichée{registrationRows.length > 1 ? "s" : ""}</div>
|
||||||
|
</div>
|
||||||
|
<form method="get" style={{ display: "flex", gap: 9, alignItems: "end", flexWrap: "wrap" }}>
|
||||||
|
<div>
|
||||||
|
<label className="field-label" htmlFor="meeting-filter">Rencontre</label>
|
||||||
|
<select id="meeting-filter" name="meeting" className="field" defaultValue={selectedMeetingId ?? ""} style={{ minWidth: 260 }}>
|
||||||
|
<option value="">Toutes les rencontres</option>
|
||||||
|
{meetingRows.map((meeting) => <option key={meeting.id} value={meeting.id}>{meeting.title}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button type="submit" style={secondaryButton}>Filtrer</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{registrationRows.map((registration) => (
|
||||||
|
<article key={registration.id} style={cardStyle}>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", gap: 14, alignItems: "start", flexWrap: "wrap", marginBottom: 16 }}>
|
||||||
|
<div>
|
||||||
|
<div className="font-display" style={{ color: "#26201a", fontSize: 19, fontWeight: 800 }}>{registration.attendeeName}</div>
|
||||||
|
<div style={{ color: "#8c8068", fontSize: 12.5, marginTop: 3 }}>{registration.meetingTitle} · {formatDate(registration.meetingStartsAt)}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||||
|
<span style={registration.status === "confirmed" ? confirmedBadge : pendingBadge}>{registration.status === "confirmed" ? "Confirmée" : "En attente"}</span>
|
||||||
|
<span style={registration.imageConsent ? consentBadge : refusalBadge}>{registration.imageConsent ? "Image autorisée" : "Image refusée"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form action={updateMeetingRegistration} className="grid grid-2" style={{ gap: 12 }}>
|
||||||
|
<input type="hidden" name="id" value={registration.id} />
|
||||||
|
<div>
|
||||||
|
<label className="field-label">Participant</label>
|
||||||
|
<input name="attendeeName" className="field" defaultValue={registration.attendeeName} required />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="field-label">Entreprise</label>
|
||||||
|
<input name="attendeeCompany" className="field" defaultValue={registration.attendeeCompany} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="field-label">E-mail</label>
|
||||||
|
<input name="attendeeEmail" type="email" className="field" defaultValue={registration.attendeeEmail ?? ""} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="field-label">Téléphone</label>
|
||||||
|
<input name="attendeePhone" className="field" defaultValue={registration.attendeePhone ?? ""} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="field-label">Statut</label>
|
||||||
|
<select name="status" className="field" defaultValue={registration.status}>
|
||||||
|
<option value="pending">En attente</option>
|
||||||
|
<option value="confirmed">Confirmée</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<label style={{ display: "flex", gap: 9, alignItems: "center", color: "#6c6150", fontSize: 13, paddingTop: 22 }}>
|
||||||
|
<input type="checkbox" name="imageConsent" defaultChecked={registration.imageConsent} />
|
||||||
|
Publication de l'image autorisée
|
||||||
|
</label>
|
||||||
|
<button type="submit" style={primaryButton}>Enregistrer</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", gap: 10, marginTop: 12, paddingTop: 12, borderTop: "1px solid #f0e8d6", flexWrap: "wrap" }}>
|
||||||
|
{registration.status !== "confirmed" && (
|
||||||
|
<form action={confirmMeetingRegistration}>
|
||||||
|
<input type="hidden" name="id" value={registration.id} />
|
||||||
|
<button type="submit" style={confirmButton}>Confirmer</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
<form action={deleteMeetingRegistration}>
|
||||||
|
<input type="hidden" name="id" value={registration.id} />
|
||||||
|
<button type="submit" style={dangerButton}>Annuler l'inscription</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{registrationRows.length === 0 && <div style={emptyStyle}>Aucune inscription pour cette sélection.</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sectionLabel: CSSProperties = { fontSize: 12.5, letterSpacing: "0.08em", textTransform: "uppercase", color: "#9a8d72", fontWeight: 800 };
|
||||||
|
const cardStyle: CSSProperties = { background: "#fff", border: "1px solid #e6dcc6", borderRadius: 8, padding: 20 };
|
||||||
|
const badgeBase: CSSProperties = { display: "inline-block", borderRadius: 999, padding: "5px 10px", fontSize: 11.5, fontWeight: 800 };
|
||||||
|
const confirmedBadge: CSSProperties = { ...badgeBase, background: "#e6f4ec", color: "#1f8a5b" };
|
||||||
|
const pendingBadge: CSSProperties = { ...badgeBase, background: "#fbeede", color: "#9a6638" };
|
||||||
|
const consentBadge: CSSProperties = { ...badgeBase, background: "#eaf0f6", color: "#2C6FB3" };
|
||||||
|
const refusalBadge: CSSProperties = { ...badgeBase, background: "#fbe9e6", color: "#d8472b" };
|
||||||
|
const primaryButton: CSSProperties = { border: "none", background: "#13324F", color: "#fff", borderRadius: 8, padding: "11px 16px", fontWeight: 800, cursor: "pointer" };
|
||||||
|
const secondaryButton: CSSProperties = { border: "1px solid #d8cdb4", background: "#fff", color: "#6f6450", borderRadius: 8, padding: "11px 16px", fontWeight: 800, cursor: "pointer" };
|
||||||
|
const confirmButton: CSSProperties = { border: "none", background: "#1f8a5b", color: "#fff", borderRadius: 8, padding: "9px 14px", fontWeight: 800, cursor: "pointer" };
|
||||||
|
const dangerButton: CSSProperties = { border: "1px solid #e0c3bb", background: "#fff", color: "#d8472b", borderRadius: 8, padding: "8px 13px", fontWeight: 800, cursor: "pointer" };
|
||||||
|
const emptyStyle: CSSProperties = { background: "#fff", border: "1px solid #e6dcc6", borderRadius: 8, padding: 28, color: "#8c8068", textAlign: "center" };
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import type { CSSProperties } from "react";
|
||||||
|
import { asc, desc, eq, sql } from "drizzle-orm";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/db";
|
||||||
|
import { meetingRegistrations, meetings, pastMeetingPhotos, pastMeetings } from "@/db/schema";
|
||||||
|
import { ImageField } from "@/components/ImageField";
|
||||||
|
import { can } from "@/lib/rbac";
|
||||||
|
import {
|
||||||
|
addPastMeetingPhoto,
|
||||||
|
createPastMeeting,
|
||||||
|
deletePastMeeting,
|
||||||
|
deletePastMeetingPhoto,
|
||||||
|
updatePastMeeting,
|
||||||
|
} from "../actions";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
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 PastMeetingsAdminPage() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!can(session?.user.role, "manageMeetings")) redirect("/backend");
|
||||||
|
|
||||||
|
const [meetingRows, archives, photos, refusals] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
id: meetings.id,
|
||||||
|
title: meetings.title,
|
||||||
|
startsAt: meetings.startsAt,
|
||||||
|
registered: sql<number>`count(${meetingRegistrations.id})`,
|
||||||
|
})
|
||||||
|
.from(meetings)
|
||||||
|
.leftJoin(meetingRegistrations, eq(meetingRegistrations.meetingId, meetings.id))
|
||||||
|
.groupBy(meetings.id)
|
||||||
|
.orderBy(desc(meetings.startsAt)),
|
||||||
|
db.select().from(pastMeetings).orderBy(desc(pastMeetings.eventDate)),
|
||||||
|
db.select().from(pastMeetingPhotos).orderBy(asc(pastMeetingPhotos.position), asc(pastMeetingPhotos.id)),
|
||||||
|
db
|
||||||
|
.select({ meetingId: meetingRegistrations.meetingId, attendeeName: meetingRegistrations.attendeeName })
|
||||||
|
.from(meetingRegistrations)
|
||||||
|
.where(eq(meetingRegistrations.imageConsent, false))
|
||||||
|
.orderBy(asc(meetingRegistrations.attendeeName)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const photosByPast = new Map<number, typeof photos>();
|
||||||
|
for (const photo of photos) photosByPast.set(photo.pastMeetingId, [...(photosByPast.get(photo.pastMeetingId) ?? []), photo]);
|
||||||
|
const refusalsByMeeting = new Map<number, string[]>();
|
||||||
|
for (const refusal of refusals) refusalsByMeeting.set(refusal.meetingId, [...(refusalsByMeeting.get(refusal.meetingId) ?? []), refusal.attendeeName]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: "grid", gap: 28 }}>
|
||||||
|
<section style={panelStyle}>
|
||||||
|
<h2 className="font-display" style={{ margin: "0 0 6px", fontSize: 20, color: "#26201a" }}>Nouvelle rencontre passée</h2>
|
||||||
|
<p style={{ color: "#8c8068", fontSize: 13, margin: "0 0 18px" }}>Les archives publiées apparaissent sur la page publique « Rencontres passées ».</p>
|
||||||
|
<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">Rencontre liée</label>
|
||||||
|
<select name="meetingId" className="field" defaultValue="">
|
||||||
|
<option value="">Aucune</option>
|
||||||
|
{meetingRows.map((meeting) => <option key={meeting.id} value={meeting.id}>{meeting.title} ({Number(meeting.registered)} participants)</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, uniquement sans rencontre 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}>Archives publiées</div>
|
||||||
|
<div className="grid grid-3" style={{ gap: 18 }}>
|
||||||
|
{archives.map((archive) => {
|
||||||
|
const refused = archive.meetingId ? refusalsByMeeting.get(archive.meetingId) ?? [] : [];
|
||||||
|
return (
|
||||||
|
<article key={archive.id} style={cardStyle}>
|
||||||
|
{refused.length > 0 && (
|
||||||
|
<div style={{ background: "#fbe9e6", border: "1px solid #f0c4bb", color: "#b53a25", borderRadius: 8, padding: "11px 13px", fontSize: 12.5, lineHeight: 1.5, marginBottom: 13 }}>
|
||||||
|
<strong>Droit à l'image refusé :</strong> {refused.join(", ")}. Vérifiez que ces personnes ne figurent pas sur les photos publiées.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<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>
|
||||||
|
{meetingRows.map((meeting) => <option key={meeting.id} value={meeting.id}>{meeting.title} ({Number(meeting.registered)})</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" aria-label={`Supprimer la photo ${photo.caption ?? ""}`} 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>
|
||||||
|
{archives.length === 0 && <div style={emptyStyle}>Aucune rencontre passée publiée.</div>}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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" };
|
||||||
@@ -1,23 +1,15 @@
|
|||||||
|
import Link from "next/link";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import type { CSSProperties } from "react";
|
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 { auth } from "@/auth";
|
||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { meetingRegistrations, meetings, pastMeetingPhotos, pastMeetings } from "@/db/schema";
|
import { meetingRegistrations, meetings } from "@/db/schema";
|
||||||
import { ImageField } from "@/components/ImageField";
|
import { ImageField } from "@/components/ImageField";
|
||||||
import { MeetingEmailComposer } from "@/components/MeetingEmailComposer";
|
import { MeetingEmailComposer } from "@/components/MeetingEmailComposer";
|
||||||
import { can } from "@/lib/rbac";
|
import { can } from "@/lib/rbac";
|
||||||
import { getSiteSettings } from "@/lib/site-settings";
|
import { getSiteSettings } from "@/lib/site-settings";
|
||||||
import {
|
import { createMeeting, deleteMeeting, updateMeeting } from "../actions";
|
||||||
addPastMeetingPhoto,
|
|
||||||
createMeeting,
|
|
||||||
createPastMeeting,
|
|
||||||
deleteMeeting,
|
|
||||||
deletePastMeeting,
|
|
||||||
deletePastMeetingPhoto,
|
|
||||||
updateMeeting,
|
|
||||||
updatePastMeeting,
|
|
||||||
} from "../actions";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -27,38 +19,29 @@ function dateTimeValue(date: Date) {
|
|||||||
return local.toISOString().slice(0, 16);
|
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() {
|
export default async function RencontresAdminPage() {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!can(session?.user.role, "manageMeetings")) redirect("/backend");
|
if (!can(session?.user.role, "manageMeetings")) redirect("/backend");
|
||||||
|
|
||||||
const rows = await db
|
const [rows, settings] = await Promise.all([
|
||||||
.select({
|
db
|
||||||
id: meetings.id,
|
.select({
|
||||||
title: meetings.title,
|
id: meetings.id,
|
||||||
startsAt: meetings.startsAt,
|
title: meetings.title,
|
||||||
location: meetings.location,
|
startsAt: meetings.startsAt,
|
||||||
description: meetings.description,
|
location: meetings.location,
|
||||||
capacity: meetings.capacity,
|
description: meetings.description,
|
||||||
imageUrl: meetings.imageUrl,
|
capacity: meetings.capacity,
|
||||||
registered: sql<number>`count(${meetingRegistrations.id})`,
|
participantsPerAccount: meetings.participantsPerAccount,
|
||||||
})
|
imageUrl: meetings.imageUrl,
|
||||||
.from(meetings)
|
registered: sql<number>`count(${meetingRegistrations.id})`,
|
||||||
.leftJoin(meetingRegistrations, eq(meetingRegistrations.meetingId, meetings.id))
|
})
|
||||||
.groupBy(meetings.id)
|
.from(meetings)
|
||||||
.orderBy(desc(meetings.startsAt));
|
.leftJoin(meetingRegistrations, eq(meetingRegistrations.meetingId, meetings.id))
|
||||||
|
.groupBy(meetings.id)
|
||||||
const archives = await db.select().from(pastMeetings).orderBy(desc(pastMeetings.eventDate));
|
.orderBy(desc(meetings.startsAt)),
|
||||||
const photos = await db
|
getSiteSettings(),
|
||||||
.select()
|
]);
|
||||||
.from(pastMeetingPhotos)
|
|
||||||
.orderBy(asc(pastMeetingPhotos.position), asc(pastMeetingPhotos.id));
|
|
||||||
const settings = await getSiteSettings();
|
|
||||||
const emailBrand = {
|
const emailBrand = {
|
||||||
associationName: settings.association_name,
|
associationName: settings.association_name,
|
||||||
address: settings.association_address,
|
address: settings.association_address,
|
||||||
@@ -67,17 +50,10 @@ export default async function RencontresAdminPage() {
|
|||||||
siret: settings.association_siret,
|
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 (
|
return (
|
||||||
<div style={{ display: "grid", gap: 28 }}>
|
<div style={{ display: "grid", gap: 28 }}>
|
||||||
<section style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 16, padding: 22 }}>
|
<section style={panelStyle}>
|
||||||
<h2 className="font-display" style={{ margin: "0 0 16px", fontSize: 20, color: "#26201a" }}>
|
<h2 className="font-display" style={{ margin: "0 0 16px", fontSize: 20, color: "#26201a" }}>Nouvelle rencontre</h2>
|
||||||
Nouvelle rencontre
|
|
||||||
</h2>
|
|
||||||
<form action={createMeeting} className="grid grid-2" style={{ gap: 16 }}>
|
<form action={createMeeting} className="grid grid-2" style={{ gap: 16 }}>
|
||||||
<div>
|
<div>
|
||||||
<label className="field-label">Titre</label>
|
<label className="field-label">Titre</label>
|
||||||
@@ -91,9 +67,15 @@ export default async function RencontresAdminPage() {
|
|||||||
<label className="field-label">Lieu</label>
|
<label className="field-label">Lieu</label>
|
||||||
<input name="location" className="field" />
|
<input name="location" className="field" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="grid grid-2" style={{ gap: 12 }}>
|
||||||
<label className="field-label">Places</label>
|
<div>
|
||||||
<input name="capacity" type="number" min="1" defaultValue={30} className="field" />
|
<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>
|
||||||
<div style={{ gridColumn: "1 / -1" }}>
|
<div style={{ gridColumn: "1 / -1" }}>
|
||||||
<label className="field-label">Description</label>
|
<label className="field-label">Description</label>
|
||||||
@@ -102,14 +84,12 @@ export default async function RencontresAdminPage() {
|
|||||||
<div style={{ gridColumn: "1 / -1" }}>
|
<div style={{ gridColumn: "1 / -1" }}>
|
||||||
<ImageField name="imageUrl" label="Image" height={160} />
|
<ImageField name="imageUrl" label="Image" height={160} />
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" style={primaryButton}>
|
<button type="submit" style={primaryButton}>Ajouter la rencontre</button>
|
||||||
Ajouter la rencontre
|
|
||||||
</button>
|
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<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 }}>
|
<div className="grid grid-3" style={{ gap: 18 }}>
|
||||||
{rows.map((meeting) => (
|
{rows.map((meeting) => (
|
||||||
<article key={meeting.id} style={cardStyle}>
|
<article key={meeting.id} style={cardStyle}>
|
||||||
@@ -128,19 +108,27 @@ export default async function RencontresAdminPage() {
|
|||||||
<label className="field-label">Lieu</label>
|
<label className="field-label">Lieu</label>
|
||||||
<input name="location" defaultValue={meeting.location ?? ""} className="field" />
|
<input name="location" defaultValue={meeting.location ?? ""} className="field" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="grid grid-2" style={{ gap: 10 }}>
|
||||||
<label className="field-label">Places</label>
|
<div>
|
||||||
<input name="capacity" type="number" min="1" defaultValue={meeting.capacity} className="field" />
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<label className="field-label">Description</label>
|
<label className="field-label">Description</label>
|
||||||
<textarea name="description" rows={3} defaultValue={meeting.description ?? ""} className="field" />
|
<textarea name="description" rows={3} defaultValue={meeting.description ?? ""} className="field" />
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 12.5, color: "#6c6150", fontWeight: 800 }}>
|
|
||||||
{Number(meeting.registered)} inscrit(s)
|
|
||||||
</div>
|
|
||||||
<button type="submit" style={primaryButton}>Enregistrer</button>
|
<button type="submit" style={primaryButton}>Enregistrer</button>
|
||||||
</form>
|
</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
|
<MeetingEmailComposer
|
||||||
meeting={{
|
meeting={{
|
||||||
id: meeting.id,
|
id: meeting.id,
|
||||||
@@ -160,153 +148,15 @@ export default async function RencontresAdminPage() {
|
|||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
{rows.length === 0 && <div style={emptyStyle}>Aucune rencontre programmée.</div>}
|
||||||
|
|
||||||
<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>
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const cardStyle: CSSProperties = {
|
const panelStyle: CSSProperties = { background: "#fff", border: "1px solid #e6dcc6", borderRadius: 8, padding: 22 };
|
||||||
background: "#fff",
|
const cardStyle: CSSProperties = { background: "#fff", border: "1px solid #e6dcc6", borderRadius: 8, padding: 16 };
|
||||||
border: "1px solid #e6dcc6",
|
const sectionTitle: CSSProperties = { fontSize: 12.5, letterSpacing: "0.08em", textTransform: "uppercase", color: "#9a8d72", fontWeight: 800, marginBottom: 12 };
|
||||||
borderRadius: 12,
|
const primaryButton: CSSProperties = { border: "none", background: "#13324F", color: "#fff", fontWeight: 800, fontSize: 13.5, padding: "11px 16px", borderRadius: 8, cursor: "pointer" };
|
||||||
padding: 16,
|
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" };
|
||||||
|
|
||||||
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",
|
|
||||||
};
|
|
||||||
@@ -534,6 +534,17 @@ img {
|
|||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.meeting-registration-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.meeting-registration-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
.email-workbench,
|
.email-workbench,
|
||||||
.meeting-email-workbench { grid-template-columns: 1fr; }
|
.meeting-email-workbench { grid-template-columns: 1fr; }
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { and, asc, eq, gte, sql } from "drizzle-orm";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { MeetingRegistrationForm } from "@/components/MeetingRegistrationForm";
|
||||||
|
import { SiteFooter } from "@/components/SiteFooter";
|
||||||
|
import { SiteHeader } from "@/components/SiteHeader";
|
||||||
|
import { db } from "@/db";
|
||||||
|
import { meetingRegistrations, meetings, members } from "@/db/schema";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
function formatDate(date: Date) {
|
||||||
|
return new Intl.DateTimeFormat("fr-FR", {
|
||||||
|
weekday: "long",
|
||||||
|
day: "numeric",
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(date: Date) {
|
||||||
|
return new Intl.DateTimeFormat("fr-FR", { hour: "2-digit", minute: "2-digit" }).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function MeetingRegistrationPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params;
|
||||||
|
const meetingId = Number(id);
|
||||||
|
const session = await auth();
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
const [meeting] = Number.isInteger(meetingId)
|
||||||
|
? await 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))
|
||||||
|
.where(and(eq(meetings.id, meetingId), gte(meetings.startsAt, now)))
|
||||||
|
.groupBy(meetings.id)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const memberId = session?.user.memberId ?? null;
|
||||||
|
const [member, existing] = memberId && meeting
|
||||||
|
? await Promise.all([
|
||||||
|
db
|
||||||
|
.select({ name: members.name, status: members.status })
|
||||||
|
.from(members)
|
||||||
|
.where(eq(members.id, memberId))
|
||||||
|
.then((rows) => rows[0] ?? null),
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
name: meetingRegistrations.attendeeName,
|
||||||
|
imageConsent: meetingRegistrations.imageConsent,
|
||||||
|
status: meetingRegistrations.status,
|
||||||
|
})
|
||||||
|
.from(meetingRegistrations)
|
||||||
|
.where(and(eq(meetingRegistrations.meetingId, meeting.id), eq(meetingRegistrations.memberId, memberId)))
|
||||||
|
.orderBy(asc(meetingRegistrations.createdAt), asc(meetingRegistrations.id)),
|
||||||
|
])
|
||||||
|
: [null, []];
|
||||||
|
|
||||||
|
const registered = meeting ? Number(meeting.registered) : 0;
|
||||||
|
const remaining = meeting ? Math.max(0, meeting.capacity - registered) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ background: "#F6F2E8", minHeight: "100vh", fontFamily: "'Public Sans',sans-serif", color: "#33291D" }}>
|
||||||
|
<SiteHeader active="association" logo />
|
||||||
|
<main>
|
||||||
|
{!meeting ? (
|
||||||
|
<section className="container" style={{ paddingTop: 72, paddingBottom: 96, maxWidth: 760 }}>
|
||||||
|
<div style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 8, padding: "32px clamp(22px, 5vw, 38px)" }}>
|
||||||
|
<div style={{ color: "#9a6638", fontSize: 12, letterSpacing: "0.14em", textTransform: "uppercase", fontWeight: 800 }}>Rencontre</div>
|
||||||
|
<h1 className="font-display" style={{ color: "#26201a", fontSize: 34, margin: "8px 0 12px" }}>Inscription indisponible</h1>
|
||||||
|
<p style={{ color: "#6c6150", lineHeight: 1.7 }}>Cette rencontre n'existe pas ou les inscriptions sont terminées.</p>
|
||||||
|
<Link href="/association" style={{ display: "inline-block", marginTop: 12, color: "#9a6638", fontWeight: 800, textDecoration: "none" }}>Voir les prochaines rencontres</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<section className="container" style={{ paddingTop: 40, paddingBottom: 80 }}>
|
||||||
|
<div style={{ gap: 28, alignItems: "start" }} className="meeting-registration-layout">
|
||||||
|
<article style={{ background: "#13324F", color: "#fff", borderRadius: 8, overflow: "hidden" }}>
|
||||||
|
<div style={{ aspectRatio: "16 / 9", background: meeting.imageUrl ? `center/cover no-repeat url(${meeting.imageUrl})` : "repeating-linear-gradient(45deg,#244965,#244965 12px,#2d5571 12px,#2d5571 24px)" }} />
|
||||||
|
<div style={{ padding: 24 }}>
|
||||||
|
<div style={{ color: "#E0A63C", fontSize: 12.5, fontWeight: 800 }}>{formatDate(meeting.startsAt)} · {formatTime(meeting.startsAt)}</div>
|
||||||
|
<h1 className="font-display" style={{ fontSize: 30, margin: "9px 0 8px" }}>{meeting.title}</h1>
|
||||||
|
{meeting.location && <div style={{ color: "#cfe0ee", fontSize: 14, fontWeight: 700 }}>{meeting.location}</div>}
|
||||||
|
{meeting.description && <p style={{ color: "#cfe0ee", lineHeight: 1.7, margin: "16px 0" }}>{meeting.description}</p>}
|
||||||
|
<div style={{ borderTop: "1px solid rgba(255,255,255,.16)", paddingTop: 15, color: "#fff", fontSize: 13.5, fontWeight: 800 }}>
|
||||||
|
{registered}/{meeting.capacity} participants · {remaining} place{remaining > 1 ? "s" : ""} restante{remaining > 1 ? "s" : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<section style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 8, padding: "clamp(22px, 4vw, 30px)" }}>
|
||||||
|
<div style={{ color: "#9a6638", fontSize: 12, letterSpacing: "0.14em", textTransform: "uppercase", fontWeight: 800 }}>Inscription membre</div>
|
||||||
|
<h2 className="font-display" style={{ color: "#26201a", fontSize: 28, margin: "8px 0 20px" }}>
|
||||||
|
{existing.length > 0 ? "Modifier les participants" : "Inscrire les participants"}
|
||||||
|
</h2>
|
||||||
|
{!session?.user || !memberId ? (
|
||||||
|
<div>
|
||||||
|
<p style={{ color: "#6c6150", lineHeight: 1.7 }}>Connectez-vous avec votre compte adhérent avant de choisir les participants.</p>
|
||||||
|
<Link href="/login" style={{ display: "inline-block", background: "#13324F", color: "#fff", borderRadius: 8, padding: "13px 20px", fontWeight: 800, textDecoration: "none" }}>Se connecter</Link>
|
||||||
|
</div>
|
||||||
|
) : member?.status !== "active" ? (
|
||||||
|
<div style={{ background: "#fbeede", border: "1px solid #ecd8b8", color: "#9a6638", borderRadius: 8, padding: "14px 16px", lineHeight: 1.6 }}>
|
||||||
|
Votre adhésion doit être active avant de pouvoir vous inscrire aux rencontres.
|
||||||
|
</div>
|
||||||
|
) : remaining === 0 && existing.length === 0 ? (
|
||||||
|
<div style={{ background: "#f1efe7", border: "1px solid #ded8c8", color: "#6c6150", borderRadius: 8, padding: "14px 16px" }}>Cette rencontre est complète.</div>
|
||||||
|
) : (
|
||||||
|
<MeetingRegistrationForm
|
||||||
|
meetingId={meeting.id}
|
||||||
|
defaultName={session.user.name ?? member.name}
|
||||||
|
existing={existing}
|
||||||
|
maxPerAccount={meeting.participantsPerAccount}
|
||||||
|
remaining={remaining}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
<SiteFooter />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { asc, desc, inArray } from "drizzle-orm";
|
||||||
|
import { PastMeetingGallery } from "@/components/PastMeetingGallery";
|
||||||
|
import { SiteFooter } from "@/components/SiteFooter";
|
||||||
|
import { SiteHeader } from "@/components/SiteHeader";
|
||||||
|
import { db } from "@/db";
|
||||||
|
import { meetingRegistrations, pastMeetingPhotos, pastMeetings } from "@/db/schema";
|
||||||
|
import { splitLines } from "@/lib/site-settings";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
function formatDate(date: Date) {
|
||||||
|
return new Intl.DateTimeFormat("fr-FR", { day: "numeric", month: "long", year: "numeric" }).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function PastMeetingsPage() {
|
||||||
|
const [archives, photos] = await Promise.all([
|
||||||
|
db.select().from(pastMeetings).orderBy(desc(pastMeetings.eventDate)),
|
||||||
|
db.select().from(pastMeetingPhotos).orderBy(asc(pastMeetingPhotos.position), asc(pastMeetingPhotos.id)),
|
||||||
|
]);
|
||||||
|
const linkedIds = archives.map((archive) => archive.meetingId).filter((id): id is number => id !== null);
|
||||||
|
const linkedRegistrations = linkedIds.length > 0
|
||||||
|
? await db
|
||||||
|
.select({ meetingId: meetingRegistrations.meetingId, id: meetingRegistrations.id })
|
||||||
|
.from(meetingRegistrations)
|
||||||
|
.where(inArray(meetingRegistrations.meetingId, linkedIds))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const photosByPast = new Map<number, typeof photos>();
|
||||||
|
for (const photo of photos) photosByPast.set(photo.pastMeetingId, [...(photosByPast.get(photo.pastMeetingId) ?? []), photo]);
|
||||||
|
const registrationCounts = new Map<number, number>();
|
||||||
|
for (const registration of linkedRegistrations) registrationCounts.set(registration.meetingId, (registrationCounts.get(registration.meetingId) ?? 0) + 1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ background: "#F6F2E8", minHeight: "100vh", fontFamily: "'Public Sans',sans-serif", color: "#33291D" }}>
|
||||||
|
<SiteHeader active="past" logo />
|
||||||
|
<main>
|
||||||
|
<section style={{ background: "#13324F", color: "#fff" }}>
|
||||||
|
<div className="container" style={{ paddingTop: 58, paddingBottom: 54 }}>
|
||||||
|
<div style={{ color: "#E0A63C", fontSize: 12, letterSpacing: "0.14em", textTransform: "uppercase", fontWeight: 800 }}>Ils y étaient</div>
|
||||||
|
<h1 className="font-display" style={{ margin: "10px 0 14px", fontSize: "clamp(38px, 7vw, 62px)", lineHeight: 1 }}>Nos rencontres passées</h1>
|
||||||
|
<p style={{ maxWidth: 680, color: "#cfe0ee", fontSize: 17, lineHeight: 1.7, margin: 0 }}>
|
||||||
|
Revivez en images les temps forts de Plein R et les moments partagés par les acteurs économiques du Bassin de Pompey.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="container" style={{ paddingTop: 54, paddingBottom: 80 }}>
|
||||||
|
{archives.length === 0 ? (
|
||||||
|
<div style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 8, padding: 34, color: "#8c8068", textAlign: "center" }}>Aucune rencontre passée publiée pour le moment.</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-3" style={{ gap: 20 }}>
|
||||||
|
{archives.map((archive) => {
|
||||||
|
const participantCount = archive.meetingId
|
||||||
|
? registrationCounts.get(archive.meetingId) ?? 0
|
||||||
|
: splitLines(archive.participants ?? "").length;
|
||||||
|
return (
|
||||||
|
<article key={archive.id} style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 8, padding: 18 }}>
|
||||||
|
<div style={{ color: "#9a6638", fontSize: 12.5, fontWeight: 800 }}>{formatDate(archive.eventDate)}</div>
|
||||||
|
<h2 className="font-display" style={{ color: "#26201a", fontSize: 23, margin: "7px 0" }}>{archive.title}</h2>
|
||||||
|
{archive.location && <div style={{ color: "#6c6150", fontSize: 13, fontWeight: 700 }}>{archive.location}</div>}
|
||||||
|
{archive.description && <p style={{ color: "#8c8068", fontSize: 13.5, lineHeight: 1.65 }}>{archive.description}</p>}
|
||||||
|
<div style={{ color: "#6c6150", fontSize: 12.5, fontWeight: 800, marginBottom: 12 }}>
|
||||||
|
{participantCount} participant{participantCount > 1 ? "s" : ""} · {(photosByPast.get(archive.id) ?? []).length} photo{(photosByPast.get(archive.id) ?? []).length > 1 ? "s" : ""}
|
||||||
|
</div>
|
||||||
|
<PastMeetingGallery title={archive.title} photos={photosByPast.get(archive.id) ?? []} />
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<SiteFooter />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useActionState, useEffect, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import {
|
||||||
|
saveMeetingRegistration,
|
||||||
|
type MeetingRegistrationState,
|
||||||
|
} from "@/app/backend/actions";
|
||||||
|
|
||||||
|
type Participant = {
|
||||||
|
name: string;
|
||||||
|
imageConsent: boolean;
|
||||||
|
status?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const INITIAL_STATE: MeetingRegistrationState = { status: "idle", message: "" };
|
||||||
|
|
||||||
|
export function MeetingRegistrationForm({
|
||||||
|
meetingId,
|
||||||
|
defaultName,
|
||||||
|
existing,
|
||||||
|
maxPerAccount,
|
||||||
|
remaining,
|
||||||
|
}: {
|
||||||
|
meetingId: number;
|
||||||
|
defaultName: string;
|
||||||
|
existing: Participant[];
|
||||||
|
maxPerAccount: number;
|
||||||
|
remaining: number;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [participants, setParticipants] = useState<Participant[]>(
|
||||||
|
existing.length > 0 ? existing : [{ name: defaultName, imageConsent: false }],
|
||||||
|
);
|
||||||
|
const [state, formAction, pending] = useActionState(saveMeetingRegistration, INITIAL_STATE);
|
||||||
|
const maxAllowed = Math.max(existing.length, Math.min(maxPerAccount, existing.length + remaining));
|
||||||
|
const registered = existing.length > 0;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (state.status !== "success") return;
|
||||||
|
if (state.registrationCount === 0) {
|
||||||
|
setParticipants([{ name: defaultName, imageConsent: false }]);
|
||||||
|
}
|
||||||
|
router.refresh();
|
||||||
|
}, [defaultName, router, state]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={formAction} style={{ display: "grid", gap: 18 }}>
|
||||||
|
<input type="hidden" name="meetingId" value={meetingId} />
|
||||||
|
<input type="hidden" name="participantCount" value={participants.length} />
|
||||||
|
|
||||||
|
<div style={{ background: "#f6f2e8", border: "1px solid #e6dcc6", borderRadius: 8, padding: "13px 15px", color: "#6c6150", fontSize: 13.5, lineHeight: 1.6 }}>
|
||||||
|
Maximum {maxPerAccount} participant{maxPerAccount > 1 ? "s" : ""} par compte membre.
|
||||||
|
{registered && (
|
||||||
|
<span style={{ display: "block", color: "#9a6638", fontWeight: 800, marginTop: 3 }}>
|
||||||
|
{existing.every((participant) => participant.status === "confirmed")
|
||||||
|
? "Inscription confirmée"
|
||||||
|
: "Inscription en attente de validation"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "grid", gap: 12 }}>
|
||||||
|
{participants.map((participant, index) => (
|
||||||
|
<div key={index} style={{ border: "1px solid #e6dcc6", borderRadius: 8, padding: 16, background: "#fff" }}>
|
||||||
|
<div style={{ display: "flex", gap: 10, alignItems: "end" }}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<label className="field-label" htmlFor={`participant-${index}`}>
|
||||||
|
Participant {index + 1} - nom et prénom
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={`participant-${index}`}
|
||||||
|
name={`participantName-${index}`}
|
||||||
|
className="field"
|
||||||
|
required
|
||||||
|
maxLength={200}
|
||||||
|
value={participant.name}
|
||||||
|
onChange={(event) =>
|
||||||
|
setParticipants((current) =>
|
||||||
|
current.map((item, itemIndex) =>
|
||||||
|
itemIndex === index ? { ...item, name: event.target.value } : item,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{participants.length > 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`Retirer le participant ${index + 1}`}
|
||||||
|
title="Retirer"
|
||||||
|
onClick={() => setParticipants((current) => current.filter((_, itemIndex) => itemIndex !== index))}
|
||||||
|
style={{ width: 42, height: 42, border: "1px solid #e0c3bb", background: "#fff", color: "#d8472b", borderRadius: 8, cursor: "pointer", fontSize: 20 }}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<label style={{ display: "flex", gap: 9, alignItems: "start", color: "#6c6150", fontSize: 12.5, lineHeight: 1.5, marginTop: 11 }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name={`imageConsent-${index}`}
|
||||||
|
checked={participant.imageConsent}
|
||||||
|
onChange={() =>
|
||||||
|
setParticipants((current) =>
|
||||||
|
current.map((item, itemIndex) =>
|
||||||
|
itemIndex === index ? { ...item, imageConsent: !item.imageConsent } : item,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
style={{ marginTop: 3 }}
|
||||||
|
/>
|
||||||
|
Cette personne autorise la publication de son image sur les supports de Plein R.
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{participants.length < maxAllowed && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setParticipants((current) => [...current, { name: "", imageConsent: false }])}
|
||||||
|
style={{ justifySelf: "start", border: "none", background: "transparent", color: "#9a6638", fontWeight: 800, cursor: "pointer", padding: 0 }}
|
||||||
|
>
|
||||||
|
+ Ajouter un participant
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<label style={{ display: "flex", gap: 9, alignItems: "start", color: "#6c6150", fontSize: 12.5, lineHeight: 1.55 }}>
|
||||||
|
<input type="checkbox" name="attestation" required style={{ marginTop: 3 }} />
|
||||||
|
J'atteste avoir informé chaque personne inscrite que des photos peuvent être prises et avoir recueilli son accord ou son refus ci-dessus.
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{state.message && (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
style={{
|
||||||
|
background: state.status === "success" ? "#e6f4ec" : "#fbe9e6",
|
||||||
|
border: `1px solid ${state.status === "success" ? "#b9dfc8" : "#f0c4bb"}`,
|
||||||
|
color: state.status === "success" ? "#1f6f4a" : "#b53a25",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: "11px 14px",
|
||||||
|
fontSize: 13.5,
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{state.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
name="intent"
|
||||||
|
value="save"
|
||||||
|
disabled={pending || maxAllowed < 1}
|
||||||
|
style={{ border: "none", background: "#13324F", color: "#fff", borderRadius: 8, padding: "13px 20px", fontWeight: 800, cursor: pending ? "default" : "pointer", opacity: pending ? 0.65 : 1 }}
|
||||||
|
>
|
||||||
|
{pending ? "Enregistrement..." : registered ? "Modifier mon inscription" : "Enregistrer mon inscription"}
|
||||||
|
</button>
|
||||||
|
{registered && (
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
name="intent"
|
||||||
|
value="cancel"
|
||||||
|
formNoValidate
|
||||||
|
disabled={pending}
|
||||||
|
style={{ border: "1px solid #e0c3bb", background: "#fff", color: "#d8472b", borderRadius: 8, padding: "12px 18px", fontWeight: 800, cursor: pending ? "default" : "pointer" }}
|
||||||
|
>
|
||||||
|
Annuler mon inscription
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ const NAV = [
|
|||||||
{ key: "annuaire", label: "Annuaire", href: "/annuaire" },
|
{ key: "annuaire", label: "Annuaire", href: "/annuaire" },
|
||||||
{ key: "promotions", label: "Promotions", href: "/#promotions" },
|
{ key: "promotions", label: "Promotions", href: "/#promotions" },
|
||||||
{ key: "association", label: "L'association", href: "/association" },
|
{ key: "association", label: "L'association", href: "/association" },
|
||||||
|
{ key: "past", label: "Rencontres passées", href: "/rencontres-passees" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function SiteHeader({ active, logo = false }: { active?: string; logo?: boolean }) {
|
export function SiteHeader({ active, logo = false }: { active?: string; logo?: boolean }) {
|
||||||
|
|||||||
+7
-4
@@ -7,6 +7,7 @@ import {
|
|||||||
varchar,
|
varchar,
|
||||||
boolean,
|
boolean,
|
||||||
timestamp,
|
timestamp,
|
||||||
|
index,
|
||||||
uniqueIndex,
|
uniqueIndex,
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
import { relations } from "drizzle-orm";
|
import { relations } from "drizzle-orm";
|
||||||
@@ -140,6 +141,7 @@ export const meetings = pgTable("meetings", {
|
|||||||
location: varchar("location", { length: 240 }),
|
location: varchar("location", { length: 240 }),
|
||||||
description: text("description"),
|
description: text("description"),
|
||||||
capacity: integer("capacity").notNull().default(30),
|
capacity: integer("capacity").notNull().default(30),
|
||||||
|
participantsPerAccount: integer("participants_per_account").notNull().default(1),
|
||||||
imageUrl: text("image_url"),
|
imageUrl: text("image_url"),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
});
|
});
|
||||||
@@ -153,15 +155,16 @@ export const meetingRegistrations = pgTable(
|
|||||||
.references(() => meetings.id, { onDelete: "cascade" }),
|
.references(() => meetings.id, { onDelete: "cascade" }),
|
||||||
memberId: integer("member_id").references(() => members.id, { onDelete: "set null" }),
|
memberId: integer("member_id").references(() => members.id, { onDelete: "set null" }),
|
||||||
attendeeName: varchar("attendee_name", { length: 200 }).notNull(),
|
attendeeName: varchar("attendee_name", { length: 200 }).notNull(),
|
||||||
|
attendeeCompany: varchar("attendee_company", { length: 200 }).notNull().default(""),
|
||||||
attendeeEmail: varchar("attendee_email", { length: 200 }),
|
attendeeEmail: varchar("attendee_email", { length: 200 }),
|
||||||
|
attendeePhone: varchar("attendee_phone", { length: 40 }),
|
||||||
|
status: varchar("status", { length: 20 }).notNull().default("pending"),
|
||||||
imageConsent: boolean("image_consent").notNull().default(false),
|
imageConsent: boolean("image_consent").notNull().default(false),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
},
|
},
|
||||||
(t) => ({
|
(t) => ({
|
||||||
meetingMemberIdx: uniqueIndex("meeting_registrations_meeting_member_idx").on(
|
meetingIdx: index("meeting_registrations_meeting_idx").on(t.meetingId),
|
||||||
t.meetingId,
|
memberMeetingIdx: index("meeting_registrations_member_meeting_idx").on(t.memberId, t.meetingId),
|
||||||
t.memberId
|
|
||||||
),
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ export function buildMeetingEmail(meeting: MeetingEmailData, texts: MeetingEmail
|
|||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
}).format(date);
|
}).format(date);
|
||||||
const remaining = Math.max(0, meeting.capacity - meeting.registered);
|
const remaining = Math.max(0, meeting.capacity - meeting.registered);
|
||||||
const registrationUrl = `${baseUrl.replace(/\/$/, "")}/association#rencontre-${meeting.id}`;
|
const registrationUrl = `${baseUrl.replace(/\/$/, "")}/inscription/${meeting.id}`;
|
||||||
const subject = `Invitation Plein R — ${meeting.title} · ${dateLong}`;
|
const subject = `Invitation Plein R — ${meeting.title} · ${dateLong}`;
|
||||||
const button = cta(registrationUrl, "Je m'inscris à la rencontre");
|
const button = cta(registrationUrl, "Je m'inscris à la rencontre");
|
||||||
const body = `
|
const body = `
|
||||||
|
|||||||
Reference in new issue
Block a user