feat(admin): Sprint 4 — Organisations CE + Prestataires pirogue (CRUD)
This commit is contained in:
parent
19b4ff8293
commit
99f3bbdc71
14 changed files with 1141 additions and 0 deletions
|
|
@ -0,0 +1,71 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
|
||||
type Props = {
|
||||
action: () => Promise<{ ok: false; error: string } | { ok: true } | undefined | void>;
|
||||
memberCount: number;
|
||||
};
|
||||
|
||||
export function DeleteOrgButton({ action, memberCount }: Props) {
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [confirm, setConfirm] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function run() {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const res = await action();
|
||||
if (res && (res as { ok?: boolean }).ok === false) {
|
||||
setError((res as { error: string }).error);
|
||||
setConfirm(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (memberCount > 0) {
|
||||
return (
|
||||
<span className="rounded border border-zinc-200 bg-zinc-50 px-2 py-1 text-xs text-zinc-500">
|
||||
Suppression impossible — {memberCount} membre{memberCount > 1 ? "s" : ""} rattaché{memberCount > 1 ? "s" : ""}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
{confirm ? (
|
||||
<div className="flex items-center gap-2 rounded border border-rose-300 bg-rose-50 px-2 py-1">
|
||||
<span className="text-xs text-rose-900">Supprimer définitivement ?</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={run}
|
||||
disabled={pending}
|
||||
className="rounded bg-rose-700 px-2 py-1 text-[11px] font-semibold text-white hover:bg-rose-800 disabled:opacity-50"
|
||||
>
|
||||
Oui, supprimer
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirm(false)}
|
||||
disabled={pending}
|
||||
className="text-[11px] text-zinc-500 hover:text-zinc-900"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirm(true)}
|
||||
disabled={pending}
|
||||
className="rounded-md border border-rose-300 bg-rose-50 px-3 py-1.5 text-xs font-semibold text-rose-700 hover:bg-rose-100 disabled:opacity-50"
|
||||
>
|
||||
Supprimer l'organisation
|
||||
</button>
|
||||
)}
|
||||
{error ? (
|
||||
<div className="rounded border border-rose-200 bg-rose-50 px-2 py-1 text-xs text-rose-700">{error}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
90
src/app/admin/organizations/[id]/page.tsx
Normal file
90
src/app/admin/organizations/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { getOrganizationForAdmin } from "@/lib/admin/organizations";
|
||||
import { OrgForm } from "../_components/OrgForm";
|
||||
import { StatusBadge } from "@/components/admin/StatusBadge";
|
||||
import { deleteOrganizationAction, updateOrganizationAction } from "../actions";
|
||||
import { DeleteOrgButton } from "./_components/DeleteOrgButton";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const ROLE_LABEL: Record<string, string> = {
|
||||
OWNER: "Propriétaire",
|
||||
CE_MANAGER: "CE — Manager",
|
||||
CE_MEMBER: "CE — Membre",
|
||||
TOURIST: "Touriste",
|
||||
ADMIN: "Admin",
|
||||
};
|
||||
|
||||
type PageProps = { params: Promise<{ id: string }> };
|
||||
|
||||
export default async function EditOrgPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
const org = await getOrganizationForAdmin(id);
|
||||
if (!org) notFound();
|
||||
|
||||
const updateThis = async (fd: FormData) => {
|
||||
"use server";
|
||||
return await updateOrganizationAction(id, fd);
|
||||
};
|
||||
const deleteThis = async () => {
|
||||
"use server";
|
||||
return await deleteOrganizationAction(id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl space-y-6">
|
||||
<header className="mt-2 flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<Link href="/admin/organizations" className="text-xs text-zinc-500 hover:text-zinc-900">
|
||||
← Toutes les organisations
|
||||
</Link>
|
||||
<h1 className="mt-1 text-2xl font-semibold text-zinc-900">{org.name}</h1>
|
||||
<p className="mt-1 text-sm text-zinc-500">
|
||||
<code>/{org.slug}</code> · {org.members.length} membre{org.members.length > 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
<DeleteOrgButton action={deleteThis} memberCount={org.members.length} />
|
||||
</header>
|
||||
|
||||
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
||||
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-zinc-500">Identité</h2>
|
||||
<OrgForm
|
||||
action={updateThis}
|
||||
submitLabel="Enregistrer les modifications"
|
||||
initial={{ name: org.name, slug: org.slug, description: org.description }}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
||||
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-zinc-500">
|
||||
Membres ({org.members.length})
|
||||
</h2>
|
||||
{org.members.length === 0 ? (
|
||||
<p className="text-sm text-zinc-500">
|
||||
Aucun membre. Rattachez un utilisateur via{" "}
|
||||
<Link href="/admin/users" className="text-zinc-900 hover:underline">
|
||||
la page Utilisateurs
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-zinc-100">
|
||||
{org.members.map((m) => (
|
||||
<li key={m.id} className="flex items-center justify-between gap-3 py-2 text-sm">
|
||||
<Link href={`/admin/users/${m.id}`} className="text-zinc-900 hover:underline">
|
||||
{m.firstName} {m.lastName}
|
||||
<span className="ml-2 text-[11px] text-zinc-500">{m.email}</span>
|
||||
</Link>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="text-xs text-zinc-600">{ROLE_LABEL[m.role] ?? m.role}</span>
|
||||
<StatusBadge status={m.isActive ? "ACTIVE" : "INACTIVE"} />
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
77
src/app/admin/organizations/_components/OrgForm.tsx
Normal file
77
src/app/admin/organizations/_components/OrgForm.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { FormField, inputCls, textareaCls } from "@/components/admin/FormField";
|
||||
|
||||
type Props = {
|
||||
initial?: {
|
||||
name?: string;
|
||||
slug?: string;
|
||||
description?: string | null;
|
||||
};
|
||||
action: (fd: FormData) => Promise<{ ok: false; error: string } | { ok: true } | undefined>;
|
||||
submitLabel?: string;
|
||||
};
|
||||
|
||||
export function OrgForm({ initial = {}, action, submitLabel = "Enregistrer" }: Props) {
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
function onSubmit(formData: FormData) {
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
startTransition(async () => {
|
||||
const res = await action(formData);
|
||||
if (res && res.ok === false) setError(res.error);
|
||||
else if (res && res.ok === true) setSuccess("Organisation enregistrée.");
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={onSubmit} className="space-y-4">
|
||||
<fieldset disabled={pending} className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<FormField label="Nom" required>
|
||||
<input name="name" defaultValue={initial.name ?? ""} required maxLength={200} className={inputCls} />
|
||||
</FormField>
|
||||
<FormField label="Slug" required hint="URL : /organizations/<slug>">
|
||||
<input
|
||||
name="slug"
|
||||
defaultValue={initial.slug ?? ""}
|
||||
required
|
||||
pattern="^[a-z0-9](?:[a-z0-9-]{0,80}[a-z0-9])?$"
|
||||
placeholder="ex. ce-airbus-kourou"
|
||||
className={inputCls}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
<FormField label="Description" hint="Brève présentation interne (max 5000 caractères).">
|
||||
<textarea
|
||||
name="description"
|
||||
rows={5}
|
||||
defaultValue={initial.description ?? ""}
|
||||
maxLength={5000}
|
||||
className={textareaCls}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">{error}</div>
|
||||
) : null}
|
||||
{success ? (
|
||||
<div className="rounded border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-800">{success}</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-md bg-zinc-900 px-5 py-2 text-sm font-semibold text-white hover:bg-zinc-800 disabled:opacity-50"
|
||||
>
|
||||
{pending ? "Enregistrement…" : submitLabel}
|
||||
</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
88
src/app/admin/organizations/actions.ts
Normal file
88
src/app/admin/organizations/actions.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/auth";
|
||||
import { UserRole } from "@/generated/prisma/enums";
|
||||
import { requireRole } from "@/lib/authorization";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
async function audit(event: string, target: string, actor: string | null, details: unknown) {
|
||||
console.log(JSON.stringify({ scope: "admin.organizations", event, target, actor, details, at: new Date().toISOString() }));
|
||||
}
|
||||
|
||||
const slugRe = /^[a-z0-9](?:[a-z0-9-]{0,80}[a-z0-9])?$/;
|
||||
|
||||
const orgSchema = z.object({
|
||||
name: z.string().trim().min(2).max(200),
|
||||
slug: z.string().trim().regex(slugRe, "Slug invalide (a-z, 0-9, -)"),
|
||||
description: z.string().trim().max(5000).optional().nullable(),
|
||||
});
|
||||
|
||||
function parseFD(fd: FormData) {
|
||||
return {
|
||||
name: (fd.get("name") as string | null) ?? "",
|
||||
slug: (fd.get("slug") as string | null) ?? "",
|
||||
description: ((fd.get("description") as string | null) ?? "") || null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createOrganizationAction(fd: FormData) {
|
||||
await requireRole([UserRole.ADMIN]);
|
||||
const parsed = orgSchema.safeParse(parseFD(fd));
|
||||
if (!parsed.success) {
|
||||
return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
|
||||
}
|
||||
const session = await auth();
|
||||
try {
|
||||
const created = await prisma.organization.create({
|
||||
data: { name: parsed.data.name, slug: parsed.data.slug, description: parsed.data.description ?? null },
|
||||
});
|
||||
await audit("organization.create", created.id, session?.user?.email ?? null, { slug: created.slug });
|
||||
revalidatePath("/admin/organizations");
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message.includes("Unique")) {
|
||||
return { ok: false as const, error: "Ce slug existe déjà." };
|
||||
}
|
||||
return { ok: false as const, error: "Erreur lors de la création." };
|
||||
}
|
||||
redirect("/admin/organizations");
|
||||
}
|
||||
|
||||
export async function updateOrganizationAction(id: string, fd: FormData) {
|
||||
await requireRole([UserRole.ADMIN]);
|
||||
const parsed = orgSchema.safeParse(parseFD(fd));
|
||||
if (!parsed.success) {
|
||||
return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
|
||||
}
|
||||
const session = await auth();
|
||||
try {
|
||||
await prisma.organization.update({
|
||||
where: { id },
|
||||
data: { name: parsed.data.name, slug: parsed.data.slug, description: parsed.data.description ?? null },
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message.includes("Unique")) {
|
||||
return { ok: false as const, error: "Ce slug est déjà pris." };
|
||||
}
|
||||
return { ok: false as const, error: "Erreur lors de la mise à jour." };
|
||||
}
|
||||
await audit("organization.update", id, session?.user?.email ?? null, { slug: parsed.data.slug });
|
||||
revalidatePath("/admin/organizations");
|
||||
revalidatePath(`/admin/organizations/${id}`);
|
||||
return { ok: true as const };
|
||||
}
|
||||
|
||||
export async function deleteOrganizationAction(id: string) {
|
||||
await requireRole([UserRole.ADMIN]);
|
||||
const session = await auth();
|
||||
const count = await prisma.user.count({ where: { organizationId: id } });
|
||||
if (count > 0) {
|
||||
return { ok: false as const, error: `Impossible : ${count} membre(s) encore rattaché(s).` };
|
||||
}
|
||||
await prisma.organization.delete({ where: { id } });
|
||||
await audit("organization.delete", id, session?.user?.email ?? null, {});
|
||||
revalidatePath("/admin/organizations");
|
||||
redirect("/admin/organizations");
|
||||
}
|
||||
21
src/app/admin/organizations/new/page.tsx
Normal file
21
src/app/admin/organizations/new/page.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import Link from "next/link";
|
||||
import { OrgForm } from "../_components/OrgForm";
|
||||
import { createOrganizationAction } from "../actions";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default function NewOrgPage() {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl space-y-6">
|
||||
<header className="mt-2">
|
||||
<Link href="/admin/organizations" className="text-xs text-zinc-500 hover:text-zinc-900">
|
||||
← Toutes les organisations
|
||||
</Link>
|
||||
<h1 className="mt-1 text-2xl font-semibold text-zinc-900">Nouvelle organisation</h1>
|
||||
</header>
|
||||
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
||||
<OrgForm action={createOrganizationAction} submitLabel="Créer l'organisation" />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
89
src/app/admin/organizations/page.tsx
Normal file
89
src/app/admin/organizations/page.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import Link from "next/link";
|
||||
import { listOrganizationsAdmin } from "@/lib/admin/organizations";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type PageProps = {
|
||||
searchParams: Promise<{ q?: string }>;
|
||||
};
|
||||
|
||||
export default async function OrgsAdminPage({ searchParams }: PageProps) {
|
||||
const sp = await searchParams;
|
||||
const filters = { q: sp.q?.trim() || undefined };
|
||||
const orgs = await listOrganizationsAdmin(filters);
|
||||
const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short", year: "2-digit" });
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<header className="mb-5 mt-2 flex items-end justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-zinc-900">Organisations CE</h1>
|
||||
<p className="mt-1 text-sm text-zinc-500">
|
||||
{orgs.length} résultat{orgs.length > 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/admin/organizations/new"
|
||||
className="inline-flex items-center gap-1.5 rounded-md bg-zinc-900 px-3 py-1.5 text-sm font-semibold text-white hover:bg-zinc-800"
|
||||
>
|
||||
+ Nouvelle organisation
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
<form className="mb-4 flex flex-wrap items-center gap-2 rounded-lg border border-zinc-200 bg-white p-3" method="get">
|
||||
<input
|
||||
type="text"
|
||||
name="q"
|
||||
defaultValue={filters.q ?? ""}
|
||||
placeholder="Recherche nom, slug, description…"
|
||||
className="flex-1 min-w-[220px] rounded-md border border-zinc-300 px-3 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
|
||||
/>
|
||||
<button type="submit" className="rounded-md bg-zinc-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-zinc-800">
|
||||
Filtrer
|
||||
</button>
|
||||
{filters.q ? (
|
||||
<Link href="/admin/organizations" className="text-sm text-zinc-500 hover:text-zinc-900">
|
||||
Réinit.
|
||||
</Link>
|
||||
) : null}
|
||||
</form>
|
||||
|
||||
<div className="overflow-hidden rounded-lg border border-zinc-200 bg-white">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-zinc-200 bg-zinc-50 text-xs uppercase tracking-wider text-zinc-500">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left font-semibold">Nom</th>
|
||||
<th className="px-4 py-2 text-left font-semibold">Slug</th>
|
||||
<th className="px-4 py-2 text-right font-semibold">Membres</th>
|
||||
<th className="px-4 py-2 text-right font-semibold">Créée</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-100">
|
||||
{orgs.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-4 py-8 text-center text-sm text-zinc-500">
|
||||
Aucune organisation.
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{orgs.map((o) => (
|
||||
<tr key={o.id} className="hover:bg-zinc-50">
|
||||
<td className="px-4 py-2">
|
||||
<Link href={`/admin/organizations/${o.id}`} className="font-medium text-zinc-900 hover:underline">
|
||||
{o.name}
|
||||
</Link>
|
||||
{o.description ? (
|
||||
<div className="text-[11px] text-zinc-500">{o.description.slice(0, 80)}{o.description.length > 80 ? "…" : ""}</div>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-zinc-700"><code>/{o.slug}</code></td>
|
||||
<td className="px-4 py-2 text-right font-mono text-zinc-700">{o.membersCount}</td>
|
||||
<td className="px-4 py-2 text-right text-[11px] text-zinc-500">{dateFmt.format(o.createdAt)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
type Props = {
|
||||
active: boolean;
|
||||
carbetsCount: number;
|
||||
toggleAction: (active: boolean) => Promise<{ ok: true } | { ok: false; error: string } | undefined>;
|
||||
deleteAction: () => Promise<{ ok: true } | { ok: false; error: string } | undefined | void>;
|
||||
};
|
||||
|
||||
export function ProviderInlineActions({ active, carbetsCount, toggleAction, deleteAction }: Props) {
|
||||
const router = useRouter();
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function toggle() {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const res = await toggleAction(!active);
|
||||
if (res && (res as { ok?: boolean }).ok === false) {
|
||||
setError((res as { error: string }).error);
|
||||
}
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
function del() {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const res = await deleteAction();
|
||||
if (res && (res as { ok?: boolean }).ok === false) {
|
||||
setError((res as { error: string }).error);
|
||||
setConfirmDelete(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
disabled={pending}
|
||||
className={
|
||||
active
|
||||
? "rounded-md border border-amber-300 bg-amber-50 px-3 py-1.5 text-xs font-semibold text-amber-800 hover:bg-amber-100 disabled:opacity-50"
|
||||
: "rounded-md bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
|
||||
}
|
||||
>
|
||||
{active ? "Désactiver" : "Réactiver"}
|
||||
</button>
|
||||
{carbetsCount === 0 ? (
|
||||
confirmDelete ? (
|
||||
<div className="flex items-center gap-2 rounded border border-rose-300 bg-rose-50 px-2 py-1">
|
||||
<span className="text-xs text-rose-900">Supprimer ?</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={del}
|
||||
disabled={pending}
|
||||
className="rounded bg-rose-700 px-2 py-1 text-[11px] font-semibold text-white hover:bg-rose-800 disabled:opacity-50"
|
||||
>
|
||||
Oui, supprimer
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmDelete(false)}
|
||||
disabled={pending}
|
||||
className="text-[11px] text-zinc-500 hover:text-zinc-900"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
disabled={pending}
|
||||
className="rounded-md border border-rose-300 bg-rose-50 px-3 py-1.5 text-xs font-semibold text-rose-700 hover:bg-rose-100 disabled:opacity-50"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
)
|
||||
) : (
|
||||
<span className="rounded border border-zinc-200 bg-zinc-50 px-2 py-1 text-[11px] text-zinc-500">
|
||||
Suppression impossible — {carbetsCount} carbet{carbetsCount > 1 ? "s" : ""} rattaché{carbetsCount > 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{error ? (
|
||||
<div className="rounded border border-rose-200 bg-rose-50 px-2 py-1 text-xs text-rose-700">{error}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
105
src/app/admin/pirogue-providers/[id]/page.tsx
Normal file
105
src/app/admin/pirogue-providers/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { getPirogueProviderForAdmin } from "@/lib/admin/pirogue-providers";
|
||||
import { ProviderForm } from "../_components/ProviderForm";
|
||||
import { StatusBadge } from "@/components/admin/StatusBadge";
|
||||
import {
|
||||
deletePirogueProviderAction,
|
||||
togglePirogueActiveAction,
|
||||
updatePirogueProviderAction,
|
||||
} from "../actions";
|
||||
import { ProviderInlineActions } from "./_components/ProviderInlineActions";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type PageProps = { params: Promise<{ id: string }> };
|
||||
|
||||
export default async function EditPirogueProviderPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
const p = await getPirogueProviderForAdmin(id);
|
||||
if (!p) notFound();
|
||||
|
||||
const updateThis = async (fd: FormData) => {
|
||||
"use server";
|
||||
return await updatePirogueProviderAction(id, fd);
|
||||
};
|
||||
const toggleThis = async (active: boolean) => {
|
||||
"use server";
|
||||
return await togglePirogueActiveAction(id, active);
|
||||
};
|
||||
const deleteThis = async () => {
|
||||
"use server";
|
||||
return await deletePirogueProviderAction(id);
|
||||
};
|
||||
|
||||
const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short", year: "2-digit" });
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl space-y-6">
|
||||
<header className="mt-2 flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<Link href="/admin/pirogue-providers" className="text-xs text-zinc-500 hover:text-zinc-900">
|
||||
← Tous les prestataires
|
||||
</Link>
|
||||
<h1 className="mt-1 flex items-center gap-3 text-2xl font-semibold text-zinc-900">
|
||||
{p.name}
|
||||
<StatusBadge status={p.active ? "ACTIVE" : "INACTIVE"} />
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-zinc-500">
|
||||
Fleuves : {p.rivers.length === 0 ? "—" : p.rivers.join(", ")} · {p.carbets.length} carbet
|
||||
{p.carbets.length > 1 ? "s" : ""} référencé{p.carbets.length > 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
<ProviderInlineActions
|
||||
active={p.active}
|
||||
carbetsCount={p.carbets.length}
|
||||
toggleAction={toggleThis}
|
||||
deleteAction={deleteThis}
|
||||
/>
|
||||
</header>
|
||||
|
||||
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
||||
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-zinc-500">Informations</h2>
|
||||
<ProviderForm
|
||||
action={updateThis}
|
||||
submitLabel="Enregistrer les modifications"
|
||||
initial={{
|
||||
name: p.name,
|
||||
contactEmail: p.contactEmail,
|
||||
contactPhone: p.contactPhone,
|
||||
rivers: p.rivers,
|
||||
pricingNote: p.pricingNote,
|
||||
description: p.description,
|
||||
active: p.active,
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
||||
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-zinc-500">
|
||||
Carbets référencés ({p.carbets.length})
|
||||
</h2>
|
||||
{p.carbets.length === 0 ? (
|
||||
<p className="text-sm text-zinc-500">Aucun carbet ne référence ce prestataire pour le moment.</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-zinc-100">
|
||||
{p.carbets.map((c) => (
|
||||
<li key={c.id} className="flex items-center justify-between gap-3 py-2 text-sm">
|
||||
<Link href={`/admin/carbets/${c.id}`} className="text-zinc-900 hover:underline">
|
||||
{c.title}
|
||||
<span className="ml-2 text-[11px] text-zinc-500">
|
||||
<code>/{c.slug}</code> · {c.river}
|
||||
</span>
|
||||
</Link>
|
||||
<span className="flex items-center gap-2">
|
||||
<StatusBadge status={c.status} />
|
||||
<span className="text-[11px] text-zinc-500">{dateFmt.format(c.updatedAt)}</span>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
119
src/app/admin/pirogue-providers/_components/ProviderForm.tsx
Normal file
119
src/app/admin/pirogue-providers/_components/ProviderForm.tsx
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { FormField, inputCls, textareaCls } from "@/components/admin/FormField";
|
||||
|
||||
type Props = {
|
||||
initial?: {
|
||||
name?: string;
|
||||
contactEmail?: string | null;
|
||||
contactPhone?: string | null;
|
||||
rivers?: string[];
|
||||
pricingNote?: string | null;
|
||||
description?: string | null;
|
||||
active?: boolean;
|
||||
};
|
||||
action: (fd: FormData) => Promise<{ ok: false; error: string } | { ok: true } | undefined>;
|
||||
submitLabel?: string;
|
||||
};
|
||||
|
||||
export function ProviderForm({ initial = {}, action, submitLabel = "Enregistrer" }: Props) {
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
function onSubmit(formData: FormData) {
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
startTransition(async () => {
|
||||
const res = await action(formData);
|
||||
if (res && res.ok === false) setError(res.error);
|
||||
else if (res && res.ok === true) setSuccess("Prestataire enregistré.");
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={onSubmit} className="space-y-4">
|
||||
<fieldset disabled={pending} className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<FormField label="Nom" required>
|
||||
<input name="name" defaultValue={initial.name ?? ""} required maxLength={200} className={inputCls} />
|
||||
</FormField>
|
||||
<FormField label="Email de contact">
|
||||
<input
|
||||
name="contactEmail"
|
||||
type="email"
|
||||
defaultValue={initial.contactEmail ?? ""}
|
||||
maxLength={200}
|
||||
className={inputCls}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Téléphone de contact">
|
||||
<input
|
||||
name="contactPhone"
|
||||
defaultValue={initial.contactPhone ?? ""}
|
||||
maxLength={50}
|
||||
className={inputCls}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Statut">
|
||||
<label className="flex items-center gap-2 px-1 py-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="active"
|
||||
defaultChecked={initial.active ?? true}
|
||||
className="h-4 w-4 rounded border-zinc-300"
|
||||
/>
|
||||
Prestataire actif (sélectionnable sur un carbet)
|
||||
</label>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<FormField label="Fleuves desservis" required hint="Séparer par virgule, point-virgule ou retour à la ligne.">
|
||||
<input
|
||||
name="rivers"
|
||||
defaultValue={(initial.rivers ?? []).join(", ")}
|
||||
placeholder="Maroni, Approuague, Oyapock"
|
||||
className={inputCls}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Tarification" hint="Note libre — fourchette de prix, conditions, durées.">
|
||||
<textarea
|
||||
name="pricingNote"
|
||||
rows={3}
|
||||
defaultValue={initial.pricingNote ?? ""}
|
||||
maxLength={2000}
|
||||
className={textareaCls}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Description" hint="Présentation, langues parlées, prestations annexes.">
|
||||
<textarea
|
||||
name="description"
|
||||
rows={4}
|
||||
defaultValue={initial.description ?? ""}
|
||||
maxLength={5000}
|
||||
className={textareaCls}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">{error}</div>
|
||||
) : null}
|
||||
{success ? (
|
||||
<div className="rounded border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-800">{success}</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-md bg-zinc-900 px-5 py-2 text-sm font-semibold text-white hover:bg-zinc-800 disabled:opacity-50"
|
||||
>
|
||||
{pending ? "Enregistrement…" : submitLabel}
|
||||
</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
94
src/app/admin/pirogue-providers/actions.ts
Normal file
94
src/app/admin/pirogue-providers/actions.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/auth";
|
||||
import { UserRole } from "@/generated/prisma/enums";
|
||||
import { requireRole } from "@/lib/authorization";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
async function audit(event: string, target: string, actor: string | null, details: unknown) {
|
||||
console.log(JSON.stringify({ scope: "admin.pirogue", event, target, actor, details, at: new Date().toISOString() }));
|
||||
}
|
||||
|
||||
const providerSchema = z.object({
|
||||
name: z.string().trim().min(2).max(200),
|
||||
contactEmail: z.string().trim().email().max(200).optional().nullable(),
|
||||
contactPhone: z.string().trim().max(50).optional().nullable(),
|
||||
rivers: z.array(z.string().trim().min(1).max(80)).max(20),
|
||||
pricingNote: z.string().trim().max(2000).optional().nullable(),
|
||||
description: z.string().trim().max(5000).optional().nullable(),
|
||||
active: z.boolean(),
|
||||
});
|
||||
|
||||
function parseFD(fd: FormData) {
|
||||
const riversRaw = (fd.get("rivers") as string | null) ?? "";
|
||||
const rivers = riversRaw
|
||||
.split(/[,;\n]/)
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0);
|
||||
const get = (k: string) => {
|
||||
const v = (fd.get(k) as string | null) ?? "";
|
||||
return v.trim() === "" ? null : v.trim();
|
||||
};
|
||||
return {
|
||||
name: ((fd.get("name") as string | null) ?? "").trim(),
|
||||
contactEmail: get("contactEmail"),
|
||||
contactPhone: get("contactPhone"),
|
||||
rivers,
|
||||
pricingNote: get("pricingNote"),
|
||||
description: get("description"),
|
||||
active: fd.get("active") === "on",
|
||||
};
|
||||
}
|
||||
|
||||
export async function createPirogueProviderAction(fd: FormData) {
|
||||
await requireRole([UserRole.ADMIN]);
|
||||
const parsed = providerSchema.safeParse(parseFD(fd));
|
||||
if (!parsed.success) {
|
||||
return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
|
||||
}
|
||||
const session = await auth();
|
||||
const created = await prisma.pirogueProvider.create({ data: parsed.data });
|
||||
await audit("pirogue.create", created.id, session?.user?.email ?? null, { name: created.name });
|
||||
revalidatePath("/admin/pirogue-providers");
|
||||
redirect(`/admin/pirogue-providers/${created.id}`);
|
||||
}
|
||||
|
||||
export async function updatePirogueProviderAction(id: string, fd: FormData) {
|
||||
await requireRole([UserRole.ADMIN]);
|
||||
const parsed = providerSchema.safeParse(parseFD(fd));
|
||||
if (!parsed.success) {
|
||||
return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
|
||||
}
|
||||
const session = await auth();
|
||||
await prisma.pirogueProvider.update({ where: { id }, data: parsed.data });
|
||||
await audit("pirogue.update", id, session?.user?.email ?? null, { name: parsed.data.name, active: parsed.data.active });
|
||||
revalidatePath("/admin/pirogue-providers");
|
||||
revalidatePath(`/admin/pirogue-providers/${id}`);
|
||||
return { ok: true as const };
|
||||
}
|
||||
|
||||
export async function togglePirogueActiveAction(id: string, active: boolean) {
|
||||
await requireRole([UserRole.ADMIN]);
|
||||
const session = await auth();
|
||||
await prisma.pirogueProvider.update({ where: { id }, data: { active } });
|
||||
await audit("pirogue.active.update", id, session?.user?.email ?? null, { active });
|
||||
revalidatePath("/admin/pirogue-providers");
|
||||
revalidatePath(`/admin/pirogue-providers/${id}`);
|
||||
return { ok: true as const };
|
||||
}
|
||||
|
||||
export async function deletePirogueProviderAction(id: string) {
|
||||
await requireRole([UserRole.ADMIN]);
|
||||
const session = await auth();
|
||||
const count = await prisma.carbet.count({ where: { pirogueProviderId: id } });
|
||||
if (count > 0) {
|
||||
return { ok: false as const, error: `Impossible : ${count} carbet(s) référencent ce prestataire.` };
|
||||
}
|
||||
await prisma.pirogueProvider.delete({ where: { id } });
|
||||
await audit("pirogue.delete", id, session?.user?.email ?? null, {});
|
||||
revalidatePath("/admin/pirogue-providers");
|
||||
redirect("/admin/pirogue-providers");
|
||||
}
|
||||
21
src/app/admin/pirogue-providers/new/page.tsx
Normal file
21
src/app/admin/pirogue-providers/new/page.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import Link from "next/link";
|
||||
import { ProviderForm } from "../_components/ProviderForm";
|
||||
import { createPirogueProviderAction } from "../actions";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default function NewPirogueProviderPage() {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl space-y-6">
|
||||
<header className="mt-2">
|
||||
<Link href="/admin/pirogue-providers" className="text-xs text-zinc-500 hover:text-zinc-900">
|
||||
← Tous les prestataires
|
||||
</Link>
|
||||
<h1 className="mt-1 text-2xl font-semibold text-zinc-900">Nouveau prestataire pirogue</h1>
|
||||
</header>
|
||||
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
||||
<ProviderForm action={createPirogueProviderAction} submitLabel="Créer le prestataire" />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
124
src/app/admin/pirogue-providers/page.tsx
Normal file
124
src/app/admin/pirogue-providers/page.tsx
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import Link from "next/link";
|
||||
import { listPirogueProvidersAdmin, listPirogueRivers } from "@/lib/admin/pirogue-providers";
|
||||
import { StatusBadge } from "@/components/admin/StatusBadge";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type PageProps = {
|
||||
searchParams: Promise<{
|
||||
q?: string;
|
||||
river?: string;
|
||||
active?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export default async function PirogueProvidersAdminPage({ searchParams }: PageProps) {
|
||||
const sp = await searchParams;
|
||||
const filters = {
|
||||
q: sp.q?.trim() || undefined,
|
||||
river: sp.river || undefined,
|
||||
active: sp.active === "yes" || sp.active === "no" ? (sp.active as "yes" | "no") : undefined,
|
||||
};
|
||||
const [rows, rivers] = await Promise.all([listPirogueProvidersAdmin(filters), listPirogueRivers()]);
|
||||
const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short", year: "2-digit" });
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<header className="mb-5 mt-2 flex items-end justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-zinc-900">Prestataires pirogue</h1>
|
||||
<p className="mt-1 text-sm text-zinc-500">
|
||||
{rows.length} résultat{rows.length > 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/admin/pirogue-providers/new"
|
||||
className="inline-flex items-center gap-1.5 rounded-md bg-zinc-900 px-3 py-1.5 text-sm font-semibold text-white hover:bg-zinc-800"
|
||||
>
|
||||
+ Nouveau prestataire
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
<form className="mb-4 flex flex-wrap items-center gap-2 rounded-lg border border-zinc-200 bg-white p-3" method="get">
|
||||
<input
|
||||
type="text"
|
||||
name="q"
|
||||
defaultValue={filters.q ?? ""}
|
||||
placeholder="Recherche nom, email, description…"
|
||||
className="flex-1 min-w-[220px] rounded-md border border-zinc-300 px-3 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
|
||||
/>
|
||||
<select
|
||||
name="river"
|
||||
defaultValue={filters.river ?? ""}
|
||||
className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
|
||||
>
|
||||
<option value="">Tous fleuves</option>
|
||||
{rivers.map((r) => (
|
||||
<option key={r} value={r}>{r}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
name="active"
|
||||
defaultValue={filters.active ?? ""}
|
||||
className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
|
||||
>
|
||||
<option value="">Actifs + inactifs</option>
|
||||
<option value="yes">Actifs</option>
|
||||
<option value="no">Inactifs</option>
|
||||
</select>
|
||||
<button type="submit" className="rounded-md bg-zinc-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-zinc-800">
|
||||
Filtrer
|
||||
</button>
|
||||
{(filters.q || filters.river || filters.active) ? (
|
||||
<Link href="/admin/pirogue-providers" className="text-sm text-zinc-500 hover:text-zinc-900">
|
||||
Réinit.
|
||||
</Link>
|
||||
) : null}
|
||||
</form>
|
||||
|
||||
<div className="overflow-hidden rounded-lg border border-zinc-200 bg-white">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-zinc-200 bg-zinc-50 text-xs uppercase tracking-wider text-zinc-500">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left font-semibold">Nom</th>
|
||||
<th className="px-4 py-2 text-left font-semibold">Fleuves</th>
|
||||
<th className="px-4 py-2 text-left font-semibold">Contact</th>
|
||||
<th className="px-4 py-2 text-right font-semibold">Carbets</th>
|
||||
<th className="px-4 py-2 text-left font-semibold">État</th>
|
||||
<th className="px-4 py-2 text-right font-semibold">MAJ</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-100">
|
||||
{rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-8 text-center text-sm text-zinc-500">
|
||||
Aucun prestataire ne correspond aux filtres.
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{rows.map((p) => (
|
||||
<tr key={p.id} className="hover:bg-zinc-50">
|
||||
<td className="px-4 py-2">
|
||||
<Link href={`/admin/pirogue-providers/${p.id}`} className="font-medium text-zinc-900 hover:underline">
|
||||
{p.name}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-zinc-700">
|
||||
{p.rivers.length === 0 ? <span className="text-zinc-400">—</span> : p.rivers.join(", ")}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-[11px] text-zinc-600">
|
||||
{p.contactEmail ? <div>{p.contactEmail}</div> : null}
|
||||
{p.contactPhone ? <div>{p.contactPhone}</div> : null}
|
||||
{!p.contactEmail && !p.contactPhone ? <span className="text-zinc-400">—</span> : null}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right font-mono text-zinc-700">{p.carbetsCount}</td>
|
||||
<td className="px-4 py-2"><StatusBadge status={p.active ? "ACTIVE" : "INACTIVE"} /></td>
|
||||
<td className="px-4 py-2 text-right text-[11px] text-zinc-500">{dateFmt.format(p.updatedAt)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
src/lib/admin/organizations.ts
Normal file
60
src/lib/admin/organizations.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import "server-only";
|
||||
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export type AdminOrgFilters = { q?: string };
|
||||
|
||||
export type AdminOrgListItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string | null;
|
||||
createdAt: Date;
|
||||
membersCount: number;
|
||||
};
|
||||
|
||||
export async function listOrganizationsAdmin(filters: AdminOrgFilters = {}): Promise<AdminOrgListItem[]> {
|
||||
const where: Prisma.OrganizationWhereInput = {};
|
||||
if (filters.q) {
|
||||
where.OR = [
|
||||
{ name: { contains: filters.q, mode: "insensitive" } },
|
||||
{ slug: { contains: filters.q, mode: "insensitive" } },
|
||||
{ description: { contains: filters.q, mode: "insensitive" } },
|
||||
];
|
||||
}
|
||||
|
||||
const rows = await prisma.organization.findMany({
|
||||
where,
|
||||
orderBy: [{ name: "asc" }],
|
||||
take: 200,
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
description: true,
|
||||
createdAt: true,
|
||||
_count: { select: { members: true } },
|
||||
},
|
||||
});
|
||||
return rows.map((o) => ({
|
||||
id: o.id,
|
||||
name: o.name,
|
||||
slug: o.slug,
|
||||
description: o.description,
|
||||
createdAt: o.createdAt,
|
||||
membersCount: o._count.members,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getOrganizationForAdmin(id: string) {
|
||||
return prisma.organization.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
members: {
|
||||
orderBy: [{ role: "asc" }, { lastName: "asc" }],
|
||||
select: { id: true, firstName: true, lastName: true, email: true, role: true, isActive: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
84
src/lib/admin/pirogue-providers.ts
Normal file
84
src/lib/admin/pirogue-providers.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import "server-only";
|
||||
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export type AdminPirogueFilters = {
|
||||
q?: string;
|
||||
river?: string;
|
||||
active?: "yes" | "no";
|
||||
};
|
||||
|
||||
export type AdminPirogueListItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
contactEmail: string | null;
|
||||
contactPhone: string | null;
|
||||
rivers: string[];
|
||||
active: boolean;
|
||||
carbetsCount: number;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
export async function listPirogueProvidersAdmin(filters: AdminPirogueFilters = {}): Promise<AdminPirogueListItem[]> {
|
||||
const where: Prisma.PirogueProviderWhereInput = {};
|
||||
if (filters.q) {
|
||||
where.OR = [
|
||||
{ name: { contains: filters.q, mode: "insensitive" } },
|
||||
{ contactEmail: { contains: filters.q, mode: "insensitive" } },
|
||||
{ description: { contains: filters.q, mode: "insensitive" } },
|
||||
];
|
||||
}
|
||||
if (filters.river) where.rivers = { has: filters.river };
|
||||
if (filters.active === "yes") where.active = true;
|
||||
if (filters.active === "no") where.active = false;
|
||||
|
||||
const rows = await prisma.pirogueProvider.findMany({
|
||||
where,
|
||||
orderBy: [{ active: "desc" }, { name: "asc" }],
|
||||
take: 200,
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
contactEmail: true,
|
||||
contactPhone: true,
|
||||
rivers: true,
|
||||
active: true,
|
||||
updatedAt: true,
|
||||
_count: { select: { carbets: true } },
|
||||
},
|
||||
});
|
||||
return rows.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
contactEmail: p.contactEmail,
|
||||
contactPhone: p.contactPhone,
|
||||
rivers: p.rivers,
|
||||
active: p.active,
|
||||
carbetsCount: p._count.carbets,
|
||||
updatedAt: p.updatedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getPirogueProviderForAdmin(id: string) {
|
||||
return prisma.pirogueProvider.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
carbets: {
|
||||
orderBy: [{ updatedAt: "desc" }],
|
||||
take: 20,
|
||||
select: { id: true, title: true, slug: true, river: true, status: true, updatedAt: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function listPirogueRivers(): Promise<string[]> {
|
||||
const rows = await prisma.pirogueProvider.findMany({
|
||||
where: { active: true },
|
||||
select: { rivers: true },
|
||||
});
|
||||
const set = new Set<string>();
|
||||
for (const r of rows) for (const x of r.rivers) set.add(x);
|
||||
return Array.from(set).sort();
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue