feat(admin): CRUD complet carbets + gestion médias (Sprint 2)

Server actions (src/app/admin/carbets/actions.ts) avec validation Zod :
- createCarbetAction → INSERT + audit + redirect /admin/carbets/[id]
- updateCarbetAction → UPDATE + revalidate page publique
- updateCarbetStatusAction → DRAFT/PUBLISHED/ARCHIVED
- deleteCarbetAction → soft archive (bookings/reviews FK Restrict)
- addMediaAction(carbetId, fd) → INSERT Media + sortOrder
- removeMediaAction, reorderMediaAction (transactionnel up/down)

Helpers (src/lib/admin/carbets.ts) :
- listCarbetsAdmin avec filtres (q/river/status/accessType)
- listDistinctRivers, listOwners, listPirogueProviders
- getCarbetForEdit (include owner, provider, media, _count bookings/reviews)
- Options enum pour les selects (ACCESS_TYPE, TRANSPORT_MODE, STATUS)

Pages :
- /admin/carbets : liste tableau dense avec recherche/filtres GET, status badge,
  liens vers édition, count médias/résas
- /admin/carbets/new : page création avec CarbetForm
- /admin/carbets/[id] : header titre+badge+actions, MediaManager, CarbetForm
  d'édition. Lien public si PUBLISHED.

Composants admin réutilisables :
- StatusBadge (DRAFT/PUBLISHED/ARCHIVED + statuts Booking)
- FormField + inputCls/selectCls/textareaCls
- CarbetForm (client, 5 sections : identité, localisation, accès, séjour,
  publication) avec useTransition + erreur + succès inline
- MediaManager (client, liste + reorder ↑↓ + suppression + ajout par URL)
- StatusActions (client, publier/dépublier/archiver/réactiver avec confirm)

API :
- GET /api/admin/carbets/[id]/media pour refresh client après mutation

Audit léger en log console (JSON structuré) — Sprint 5 ajoutera la table.
This commit is contained in:
Claude Integration 2026-05-31 19:51:33 +00:00
parent 3ec7a3ff10
commit 9aa0771001
11 changed files with 1202 additions and 0 deletions

130
src/lib/admin/carbets.ts Normal file
View file

@ -0,0 +1,130 @@
/**
* Helpers admin Carbets listing avec filtres, lookup pour formulaires.
*/
import "server-only";
import { prisma } from "@/lib/prisma";
import { AccessType, CarbetStatus, TransportMode, UserRole } from "@/generated/prisma/enums";
export type AdminCarbetListItem = {
id: string;
slug: string;
title: string;
river: string;
capacity: number;
status: CarbetStatus;
accessType: AccessType;
ownerName: string;
ownerEmail: string;
mediaCount: number;
bookingsCount: number;
updatedAt: Date;
};
export type AdminCarbetFilters = {
q?: string;
river?: string;
status?: CarbetStatus;
accessType?: AccessType;
};
export async function listCarbetsAdmin(filters: AdminCarbetFilters = {}): Promise<AdminCarbetListItem[]> {
const where: Parameters<typeof prisma.carbet.findMany>[0]["where"] = {};
if (filters.q) {
where.OR = [
{ title: { contains: filters.q, mode: "insensitive" } },
{ slug: { contains: filters.q, mode: "insensitive" } },
{ river: { contains: filters.q, mode: "insensitive" } },
];
}
if (filters.river) where.river = filters.river;
if (filters.status) where.status = filters.status;
if (filters.accessType) where.accessType = filters.accessType;
const rows = await prisma.carbet.findMany({
where,
orderBy: { updatedAt: "desc" },
take: 100,
select: {
id: true,
slug: true,
title: true,
river: true,
capacity: true,
status: true,
accessType: true,
updatedAt: true,
owner: { select: { firstName: true, lastName: true, email: true } },
_count: { select: { media: true, bookings: true } },
},
});
return rows.map((r) => ({
id: r.id,
slug: r.slug,
title: r.title,
river: r.river,
capacity: r.capacity,
status: r.status,
accessType: r.accessType,
ownerName: `${r.owner.firstName} ${r.owner.lastName}`.trim() || r.owner.email,
ownerEmail: r.owner.email,
mediaCount: r._count.media,
bookingsCount: r._count.bookings,
updatedAt: r.updatedAt,
}));
}
export async function listDistinctRivers(): Promise<string[]> {
const rows = await prisma.carbet.findMany({
distinct: ["river"],
orderBy: { river: "asc" },
select: { river: true },
});
return rows.map((r) => r.river);
}
export async function listOwners() {
return await prisma.user.findMany({
where: { role: UserRole.OWNER, isActive: true },
orderBy: [{ firstName: "asc" }, { lastName: "asc" }],
select: { id: true, firstName: true, lastName: true, email: true },
});
}
export async function listPirogueProviders() {
return await prisma.pirogueProvider.findMany({
where: { active: true },
orderBy: { name: "asc" },
select: { id: true, name: true, rivers: true },
});
}
export async function getCarbetForEdit(id: string) {
return await prisma.carbet.findUnique({
where: { id },
include: {
owner: { select: { id: true, firstName: true, lastName: true, email: true } },
pirogueProvider: { select: { id: true, name: true } },
media: { orderBy: { sortOrder: "asc" } },
_count: { select: { bookings: true, reviews: true } },
},
});
}
export const ACCESS_TYPE_OPTIONS: { value: AccessType; label: string }[] = [
{ value: AccessType.ROAD_AND_RIVER, label: "🛣️ Route + fleuve" },
{ value: AccessType.RIVER_ONLY, label: "🛶 Expédition fleuve" },
];
export const TRANSPORT_MODE_OPTIONS: { value: TransportMode; label: string }[] = [
{ value: TransportMode.SELF_ARRANGE, label: "🗺️ À organiser par le voyageur" },
{ value: TransportMode.OWNER_PROVIDES, label: "👤 Le loueur fournit" },
{ value: TransportMode.PARTNER_PROVIDER, label: "🤝 Partenaire référencé" },
];
export const STATUS_OPTIONS: { value: CarbetStatus; label: string }[] = [
{ value: CarbetStatus.DRAFT, label: "Brouillon" },
{ value: CarbetStatus.PUBLISHED, label: "Publié" },
{ value: CarbetStatus.ARCHIVED, label: "Archivé" },
];