feat(ce): Sprint G — data model + admin validation
All checks were successful
CI / test (pull_request) Successful in 2m38s
All checks were successful
CI / test (pull_request) Successful in 2m38s
Schema: - Organization gagne le workflow d'approbation (approved + approvedAt + approvedBy + contactEmail). Backfill : toutes les orgs existantes (CMCK) → approved=true via migration. - Nouveau OrganizationCarbetMembership (manyToMany Org↔Carbet) pour la co-gestion CE : un Carbet a un ownerId (créateur initial) + 0..n memberships ; chaque CE_MANAGER d'une org liée peut gérer le carbet en plus de l'owner. Pour un hôte individuel = pas de membership. - RentalProvider.organizationId (nullable, SetNull on delete) : un CE peut posséder son provider ; les CE_MANAGERs membres de l'org y ont accès en plus du manager nominal. Plugin ce-management ajouté au registry (catégorie business, off par défaut). Quand off : signup CE caché + dashboard /espace-ce 404. Admin organizations : - Tab statut (Toutes / À valider [count] / Validées) avec compteur des organisations pending dans l'en-tête. - Badge statut sur la liste et la page détail. - Bouton « Valider l'organisation » sur le détail (action approveOrganizationAction → flip approved=true + approvedAt + audit log organization.approve). Idempotent : un re-appel sur une org déjà validée ne re-loggue pas. - Détail montre les compteurs carbetMemberships + rentalProviders. Migration appliquée à la DB prod (CMCK backfill validé). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d24e3b4af7
commit
946dd8d5d2
8 changed files with 271 additions and 18 deletions
54
prisma/migrations/20260603200000_ce_management/migration.sql
Normal file
54
prisma/migrations/20260603200000_ce_management/migration.sql
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
-- Sprint G : CE management.
|
||||
-- * Organization gagne le workflow d'approbation (approved + approvedAt + approvedBy)
|
||||
-- + un contactEmail dédié pour les notifications admin.
|
||||
-- * Nouveau modèle OrganizationCarbetMembership : co-gestion des carbets par les
|
||||
-- CE_MANAGERs d'une org liée. Pas de unique sur carbet → un Carbet pourrait être
|
||||
-- co-publié par plusieurs orgs (cas rare mais autorisé).
|
||||
-- * RentalProvider gagne organizationId (nullable) : un CE peut posséder son provider.
|
||||
|
||||
ALTER TABLE "Organization"
|
||||
ADD COLUMN "contactEmail" TEXT,
|
||||
ADD COLUMN "approved" BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
ADD COLUMN "approvedAt" TIMESTAMP(3),
|
||||
ADD COLUMN "approvedBy" TEXT;
|
||||
|
||||
CREATE INDEX "Organization_approved_idx" ON "Organization"("approved");
|
||||
|
||||
-- Backfill : toutes les orgs existantes sont considérées validées.
|
||||
-- (Aujourd'hui : CMCK uniquement. Les futures orgs créées via signup arriveront
|
||||
-- en approved=false par défaut.)
|
||||
UPDATE "Organization"
|
||||
SET "approved" = TRUE,
|
||||
"approvedAt" = NOW()
|
||||
WHERE "approved" = FALSE;
|
||||
|
||||
CREATE TABLE "OrganizationCarbetMembership" (
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"carbetId" TEXT NOT NULL,
|
||||
"addedByUserId" TEXT,
|
||||
"addedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "OrganizationCarbetMembership_pkey" PRIMARY KEY ("organizationId", "carbetId")
|
||||
);
|
||||
|
||||
CREATE INDEX "OrganizationCarbetMembership_carbetId_idx"
|
||||
ON "OrganizationCarbetMembership"("carbetId");
|
||||
|
||||
ALTER TABLE "OrganizationCarbetMembership"
|
||||
ADD CONSTRAINT "OrganizationCarbetMembership_organizationId_fkey"
|
||||
FOREIGN KEY ("organizationId") REFERENCES "Organization"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "OrganizationCarbetMembership"
|
||||
ADD CONSTRAINT "OrganizationCarbetMembership_carbetId_fkey"
|
||||
FOREIGN KEY ("carbetId") REFERENCES "Carbet"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "RentalProvider"
|
||||
ADD COLUMN "organizationId" TEXT;
|
||||
|
||||
CREATE INDEX "RentalProvider_organizationId_idx" ON "RentalProvider"("organizationId");
|
||||
|
||||
ALTER TABLE "RentalProvider"
|
||||
ADD CONSTRAINT "RentalProvider_organizationId_fkey"
|
||||
FOREIGN KEY ("organizationId") REFERENCES "Organization"("id")
|
||||
ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
|
@ -72,16 +72,40 @@ enum TransportMode {
|
|||
}
|
||||
|
||||
model Organization {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
slug String @unique
|
||||
description String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
slug String @unique
|
||||
description String?
|
||||
contactEmail String?
|
||||
approved Boolean @default(false)
|
||||
approvedAt DateTime?
|
||||
approvedBy String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
members User[]
|
||||
members User[]
|
||||
carbetMemberships OrganizationCarbetMembership[]
|
||||
rentalProviders RentalProvider[]
|
||||
|
||||
@@index([name])
|
||||
@@index([approved])
|
||||
}
|
||||
|
||||
/// Co-gestion des carbets côté CE. Un Carbet a toujours un ownerId (créateur initial),
|
||||
/// et zéro ou plusieurs orgs liées : un CE_MANAGER d'une org liée peut gérer le carbet
|
||||
/// en plus de l'owner. Pour un hôte individuel : aucune membership ; pour un carbet CE :
|
||||
/// 1 membership pour l'org du créateur. Plusieurs orgs possibles si co-publication.
|
||||
model OrganizationCarbetMembership {
|
||||
organizationId String
|
||||
carbetId String
|
||||
addedByUserId String?
|
||||
addedAt DateTime @default(now())
|
||||
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
carbet Carbet @relation(fields: [carbetId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([organizationId, carbetId])
|
||||
@@index([carbetId])
|
||||
}
|
||||
|
||||
model User {
|
||||
|
|
@ -157,6 +181,7 @@ model Carbet {
|
|||
bookings Booking[]
|
||||
reviews Review[]
|
||||
subscriptions Subscription[]
|
||||
organizations OrganizationCarbetMembership[]
|
||||
|
||||
@@index([ownerId])
|
||||
@@index([status])
|
||||
|
|
@ -425,6 +450,9 @@ model RentalProvider {
|
|||
name String
|
||||
isSystemD Boolean @default(false)
|
||||
managedByUserId String?
|
||||
/// Si renseigné, le provider appartient à une organisation (CE) ; tout CE_MANAGER
|
||||
/// membre de l'org peut gérer items et réservations en plus du manager nominal.
|
||||
organizationId String?
|
||||
contactEmail String?
|
||||
contactPhone String?
|
||||
rivers String[] @default([])
|
||||
|
|
@ -438,11 +466,13 @@ model RentalProvider {
|
|||
updatedAt DateTime @updatedAt
|
||||
|
||||
manager User? @relation(fields: [managedByUserId], references: [id], onDelete: SetNull)
|
||||
organization Organization? @relation(fields: [organizationId], references: [id], onDelete: SetNull)
|
||||
items RentalItem[]
|
||||
rentalBookings RentalBooking[]
|
||||
|
||||
@@index([active, approved])
|
||||
@@index([managedByUserId])
|
||||
@@index([organizationId])
|
||||
}
|
||||
|
||||
model RentalItem {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
|
||||
type Props = {
|
||||
action: () => Promise<{ ok: false; error: string } | { ok: true } | undefined | void>;
|
||||
};
|
||||
|
||||
export function ApproveOrgButton({ action }: Props) {
|
||||
const [pending, startTransition] = useTransition();
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={run}
|
||||
disabled={pending}
|
||||
className="rounded-md bg-emerald-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-60"
|
||||
>
|
||||
{pending ? "Validation…" : "Valider l'organisation"}
|
||||
</button>
|
||||
{error ? <span className="text-xs text-rose-700">{error}</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,7 +3,8 @@ 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 { approveOrganizationAction, deleteOrganizationAction, updateOrganizationAction } from "../actions";
|
||||
import { ApproveOrgButton } from "./_components/ApproveOrgButton";
|
||||
import { DeleteOrgButton } from "./_components/DeleteOrgButton";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
|
@ -31,6 +32,10 @@ export default async function EditOrgPage({ params }: PageProps) {
|
|||
"use server";
|
||||
return await deleteOrganizationAction(id);
|
||||
};
|
||||
const approveThis = async () => {
|
||||
"use server";
|
||||
return await approveOrganizationAction(id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl space-y-6">
|
||||
|
|
@ -39,12 +44,33 @@ export default async function EditOrgPage({ params }: PageProps) {
|
|||
<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>
|
||||
<h1 className="mt-1 flex items-center gap-3 text-2xl font-semibold text-zinc-900">
|
||||
{org.name}
|
||||
{org.approved ? (
|
||||
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-emerald-800 ring-1 ring-inset ring-emerald-300">
|
||||
Validée
|
||||
</span>
|
||||
) : (
|
||||
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-amber-800 ring-1 ring-inset ring-amber-300">
|
||||
À valider
|
||||
</span>
|
||||
)}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-zinc-500">
|
||||
<code>/{org.slug}</code> · {org.members.length} membre{org.members.length > 1 ? "s" : ""}
|
||||
<code>/{org.slug}</code> · {org.members.length} membre{org.members.length > 1 ? "s" : ""} ·{" "}
|
||||
{org._count.carbetMemberships} carbet{org._count.carbetMemberships > 1 ? "s" : ""} co-géré
|
||||
{org._count.carbetMemberships > 1 ? "s" : ""} · {org._count.rentalProviders} provider rental
|
||||
</p>
|
||||
{org.contactEmail ? (
|
||||
<p className="text-xs text-zinc-500">
|
||||
Contact : <a href={`mailto:${org.contactEmail}`} className="underline">{org.contactEmail}</a>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{!org.approved ? <ApproveOrgButton action={approveThis} /> : null}
|
||||
<DeleteOrgButton action={deleteThis} memberCount={org.members.length} />
|
||||
</div>
|
||||
<DeleteOrgButton action={deleteThis} memberCount={org.members.length} />
|
||||
</header>
|
||||
|
||||
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { redirect } from "next/navigation";
|
|||
import { z } from "zod";
|
||||
import { auth } from "@/auth";
|
||||
import { UserRole } from "@/generated/prisma/enums";
|
||||
import { approveOrganization as approveOrganizationLib } from "@/lib/admin/organizations";
|
||||
import { requireRole } from "@/lib/authorization";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { recordAudit } from "@/lib/admin/audit";
|
||||
|
|
@ -75,6 +76,20 @@ export async function updateOrganizationAction(id: string, fd: FormData) {
|
|||
return { ok: true as const };
|
||||
}
|
||||
|
||||
export async function approveOrganizationAction(id: string) {
|
||||
await requireRole([UserRole.ADMIN]);
|
||||
const session = await auth();
|
||||
const actor = session?.user?.email ?? null;
|
||||
const res = await approveOrganizationLib(id, actor ?? "admin");
|
||||
if (!res.ok) return res;
|
||||
if (!res.alreadyApproved) {
|
||||
await audit("organization.approve", id, actor, {});
|
||||
}
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -1,16 +1,27 @@
|
|||
import Link from "next/link";
|
||||
import { listOrganizationsAdmin } from "@/lib/admin/organizations";
|
||||
import { countPendingOrganizations, listOrganizationsAdmin } from "@/lib/admin/organizations";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type PageProps = {
|
||||
searchParams: Promise<{ q?: string }>;
|
||||
searchParams: Promise<{ q?: string; status?: string }>;
|
||||
};
|
||||
|
||||
const STATUS_VALUES = ["all", "pending", "approved"] as const;
|
||||
type StatusFilter = (typeof STATUS_VALUES)[number];
|
||||
|
||||
function isStatusFilter(s: string | undefined): s is StatusFilter {
|
||||
return STATUS_VALUES.includes(s as StatusFilter);
|
||||
}
|
||||
|
||||
export default async function OrgsAdminPage({ searchParams }: PageProps) {
|
||||
const sp = await searchParams;
|
||||
const filters = { q: sp.q?.trim() || undefined };
|
||||
const orgs = await listOrganizationsAdmin(filters);
|
||||
const approved = isStatusFilter(sp.status) ? sp.status : "all";
|
||||
const filters = { q: sp.q?.trim() || undefined, approved };
|
||||
const [orgs, pendingCount] = await Promise.all([
|
||||
listOrganizationsAdmin(filters),
|
||||
countPendingOrganizations(),
|
||||
]);
|
||||
const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short", year: "2-digit" });
|
||||
|
||||
return (
|
||||
|
|
@ -30,7 +41,35 @@ export default async function OrgsAdminPage({ searchParams }: PageProps) {
|
|||
</Link>
|
||||
</header>
|
||||
|
||||
<nav className="mb-3 flex flex-wrap gap-2 text-sm">
|
||||
{(
|
||||
[
|
||||
{ key: "all", label: "Toutes" },
|
||||
{ key: "pending", label: pendingCount > 0 ? `À valider (${pendingCount})` : "À valider" },
|
||||
{ key: "approved", label: "Validées" },
|
||||
] as { key: StatusFilter; label: string }[]
|
||||
).map((t) => {
|
||||
const href = `/admin/organizations?status=${t.key}${filters.q ? `&q=${encodeURIComponent(filters.q)}` : ""}`;
|
||||
const active = approved === t.key;
|
||||
return (
|
||||
<Link
|
||||
key={t.key}
|
||||
href={href}
|
||||
className={
|
||||
"rounded-md px-3 py-1 font-medium " +
|
||||
(active ? "bg-zinc-900 text-white" : "bg-zinc-100 text-zinc-700 hover:bg-zinc-200")
|
||||
}
|
||||
>
|
||||
{t.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<form className="mb-4 flex flex-wrap items-center gap-2 rounded-lg border border-zinc-200 bg-white p-3" method="get">
|
||||
{approved !== "all" ? (
|
||||
<input type="hidden" name="status" value={approved} />
|
||||
) : null}
|
||||
<input
|
||||
type="text"
|
||||
name="q"
|
||||
|
|
@ -53,6 +92,7 @@ export default async function OrgsAdminPage({ searchParams }: PageProps) {
|
|||
<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">Statut</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>
|
||||
|
|
@ -61,7 +101,7 @@ export default async function OrgsAdminPage({ searchParams }: PageProps) {
|
|||
<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">
|
||||
<td colSpan={5} className="px-4 py-8 text-center text-sm text-zinc-500">
|
||||
Aucune organisation.
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -76,6 +116,17 @@ export default async function OrgsAdminPage({ searchParams }: PageProps) {
|
|||
<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">
|
||||
{o.approved ? (
|
||||
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-emerald-800 ring-1 ring-inset ring-emerald-300">
|
||||
Validée
|
||||
</span>
|
||||
) : (
|
||||
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-amber-800 ring-1 ring-inset ring-amber-300">
|
||||
À valider
|
||||
</span>
|
||||
)}
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -3,13 +3,16 @@ import "server-only";
|
|||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export type AdminOrgFilters = { q?: string };
|
||||
export type AdminOrgFilters = { q?: string; approved?: "all" | "pending" | "approved" };
|
||||
|
||||
export type AdminOrgListItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string | null;
|
||||
contactEmail: string | null;
|
||||
approved: boolean;
|
||||
approvedAt: Date | null;
|
||||
createdAt: Date;
|
||||
membersCount: number;
|
||||
};
|
||||
|
|
@ -23,16 +26,21 @@ export async function listOrganizationsAdmin(filters: AdminOrgFilters = {}): Pro
|
|||
{ description: { contains: filters.q, mode: "insensitive" } },
|
||||
];
|
||||
}
|
||||
if (filters.approved === "pending") where.approved = false;
|
||||
else if (filters.approved === "approved") where.approved = true;
|
||||
|
||||
const rows = await prisma.organization.findMany({
|
||||
where,
|
||||
orderBy: [{ name: "asc" }],
|
||||
orderBy: [{ approved: "asc" }, { name: "asc" }],
|
||||
take: 200,
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
description: true,
|
||||
contactEmail: true,
|
||||
approved: true,
|
||||
approvedAt: true,
|
||||
createdAt: true,
|
||||
_count: { select: { members: true } },
|
||||
},
|
||||
|
|
@ -42,11 +50,18 @@ export async function listOrganizationsAdmin(filters: AdminOrgFilters = {}): Pro
|
|||
name: o.name,
|
||||
slug: o.slug,
|
||||
description: o.description,
|
||||
contactEmail: o.contactEmail,
|
||||
approved: o.approved,
|
||||
approvedAt: o.approvedAt,
|
||||
createdAt: o.createdAt,
|
||||
membersCount: o._count.members,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function countPendingOrganizations(): Promise<number> {
|
||||
return prisma.organization.count({ where: { approved: false } });
|
||||
}
|
||||
|
||||
export async function getOrganizationForAdmin(id: string) {
|
||||
return prisma.organization.findUnique({
|
||||
where: { id },
|
||||
|
|
@ -55,6 +70,24 @@ export async function getOrganizationForAdmin(id: string) {
|
|||
orderBy: [{ role: "asc" }, { lastName: "asc" }],
|
||||
select: { id: true, firstName: true, lastName: true, email: true, role: true, isActive: true },
|
||||
},
|
||||
_count: { select: { carbetMemberships: true, rentalProviders: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function approveOrganization(
|
||||
id: string,
|
||||
adminEmail: string,
|
||||
): Promise<{ ok: true; alreadyApproved: boolean } | { ok: false; error: string }> {
|
||||
const org = await prisma.organization.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, approved: true },
|
||||
});
|
||||
if (!org) return { ok: false, error: "Organisation introuvable" };
|
||||
if (org.approved) return { ok: true, alreadyApproved: true };
|
||||
await prisma.organization.update({
|
||||
where: { id },
|
||||
data: { approved: true, approvedAt: new Date(), approvedBy: adminEmail },
|
||||
});
|
||||
return { ok: true, alreadyApproved: false };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -117,6 +117,14 @@ export const PLUGINS: PluginDescriptor[] = [
|
|||
category: "business",
|
||||
version: "0.1.0",
|
||||
},
|
||||
{
|
||||
key: "ce-management",
|
||||
name: "Gestion des Comités d'Entreprise",
|
||||
description:
|
||||
"Permet à un CE de s'inscrire (validation admin), publier ses carbets en co-gestion (OrganizationCarbetMembership), et activer un RentalProvider org-scoped pour louer son matériel. Dashboard /espace-ce avec KPIs agrégés par organisation. Si désactivé : /espace-ce et le choix « Comité d'Entreprise » sur /inscription disparaissent.",
|
||||
category: "business",
|
||||
version: "0.1.0",
|
||||
},
|
||||
|
||||
// Contenus / i18n
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue