Compare commits

...
Sign in to create a new pull request.

39 commits

Author SHA1 Message Date
cd8c04977f fix(mobile): Sprint S — tap targets + iOS safe-area
All checks were successful
CI / test (push) Successful in 2m25s
2026-06-03 04:20:31 +00:00
Ubuntu
06b01f65e2 fix(mobile): Sprint S — tap targets 44px + iOS safe-area
All checks were successful
CI / test (pull_request) Successful in 2m36s
Polish final mobile :

1. /panier sticky cart drawer respecte la safe-area iOS Safari (notch +
   home indicator) :
   - bottom: max(0.75rem, env(safe-area-inset-bottom, 0.75rem))
   - Fallback à 0.75rem (équivalent ancien bottom-3) sur les navigateurs
     sans env().

2. AddToCart inputs et boutons remontés à min-h-44px (guideline Apple/
   Material) :
   - 2 date pickers + qty number : min-h-[44px] px-3 py-2 text-base
   - inputMode="numeric" sur qty pour clavier optimisé
   - 2 CTA buttons (Ajouter / Voir mon panier) : min-h-[44px] py-3

3. Booking form /carbets/[slug] :
   - Guest count input : min-h-[44px] px-3 py-2 text-base + inputMode
   - CTA Réserver : min-h-[44px] py-3

Avant : inputs/buttons ~36-40px (sous le seuil 44px iOS), text-sm
(14px). Après : 44px+ partout, text-base (16px) sur les inputs où le
user tape → meilleur contraste tactile et le clavier iOS ne zoom plus
(iOS zoome si font-size < 16px).

Pas de tests vitest dédiés (changements purement CSS), mais 89/89
restent verts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 04:20:05 +00:00
07301ae997 fix(mobile): Sprint R — mobile UX audit + burger menu
All checks were successful
CI / test (push) Successful in 2m22s
2026-06-03 03:54:13 +00:00
Ubuntu
62833ee4e6 fix(mobile): Sprint R — burger menu + cart badge visible mobile
All checks were successful
CI / test (pull_request) Successful in 2m39s
Issues critiques identifiées par audit screenshot iPhone 14 (390×844) :

1. SiteHeader cachait TOUTE la navigation derrière `sm:` →
   utilisateur connecté mobile sans aucun lien accessible (Favoris,
   Mes réservations, Mes locations, Mon compte, Espace hôte/CE/etc.).
   Même Connexion/Créer un compte étaient inaccessibles sur mobile
   anonyme.

2. CartBadge `hidden sm:inline` → panier complètement invisible sur
   mobile même quand des items y sont. Le user perdait la trace de
   ses ajouts.

src/components/MobileMenuButton.tsx (NEW) — client component avec :
- Bouton hamburger 9x9 visible uniquement sm:hidden
- Drawer right side, overlay sombre, scroll body bloqué quand ouvert
- 3 sections : Découvrir (publics), Mon compte (si auth), Espaces pro
  (hôte/prestataire/CE/admin selon role + plugins activés)
- Sign out via signOut() de next-auth/react (côté client — évite
  d'importer SignOutButton qui tirerait @/auth donc pg dans le bundle)
- Lien actif highlighted en emerald
- Ferme automatiquement sur changement de pathname (via useRef pour
  éviter setState-in-effect)

SiteHeader.tsx :
- Tous les liens « auth » deviennent explicitement `hidden sm:inline`
  + Connexion/Créer un compte aussi (étaient toujours visibles avant,
  surchargeaient le mobile)
- SignOutButton wrap `hidden sm:inline` pour ne pas dupliquer
- MobileMenuButton ajouté à la fin de la zone droite

CartBadge.tsx : `inline` au lieu de `hidden sm:inline` → visible
quel que soit le viewport.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 03:53:39 +00:00
5845a6b950 feat(prod): Sprint Q — reminders + cleanup cron
All checks were successful
CI / test (push) Successful in 2m27s
2026-06-03 03:24:31 +00:00
Ubuntu
a6ea488732 feat(prod): Sprint Q — reminders J-1 + cleanup cron endpoints
All checks were successful
CI / test (pull_request) Successful in 2m45s
Endpoints automatisables par cron externe (Hermes, GitHub Actions,
ou crontab système) pour gérer les tâches récurrentes de la
plateforme.

src/lib/cron-auth.ts (NEW) : isAuthorizedCronRequest(req) vérifie
l'en-tête Authorization Bearer ${CRON_TOKEN}. Le token est déjà dans
.env.production.

GET /api/cron/reminders :
- Itère bookings carbet CONFIRMED + rentalBookings CONFIRMED dont
  startDate ∈ [now+22h, now+26h] (fenêtre 4h pour absorber les
  éventuels retards de cron).
- Envoie sendBookingReminder (carbet) ou sendRentalReminder (rental).
- Compte bookingSent/bookingErrors et rentalSent/rentalErrors.
- Audit log scope=cron event=cron.reminders.run avec stats.
- Retourne JSON {ok, window, booking:{candidates,sent,errors},
  rental:{candidates,sent,errors}}.

GET /api/cron/cleanup :
- Purge OrgInviteToken expirés depuis > 30j.
- Booking PENDING + paymentStatus≠SUCCEEDED + createdAt > 7j →
  status=CANCELLED + paymentStatus=FAILED (libère le créneau).
- RentalBooking idem + delete RentalItemAvailability associée
  (libère stock) en transaction.
- Audit log scope=cron event=cron.cleanup.run avec compteurs.

src/lib/email.ts :
- sendBookingReminder(to, firstName, bookingId, title, startDate,
  slug) : email rappel J-1 avec CTA vers /reservations/[id].
- sendRentalReminder(to, firstName, rbId, providerName, startDate,
  contactInfo) : rappel pour récup matériel, affiche contacts
  provider (phone + email).

tests/lib/cron-auth.test.ts (6 cas) :
- Refus si CRON_TOKEN absent, header absent, format incorrect (Basic
  ou Token), token mismatch.
- Accept si match exact, accept avec espaces autour du token (defensive).

Total tests : 89/89 ✓.

Schedule recommandé (à brancher côté Hermes ou crontab) :
- GET /api/cron/reminders : 1× par jour à 9h (Authorization: Bearer
  $CRON_TOKEN)
- GET /api/cron/cleanup : 1× par semaine

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 03:23:58 +00:00
9bdb3666a0 feat(ce): Sprint P — seed démo CE + tests invites
All checks were successful
CI / test (push) Successful in 2m28s
2026-06-03 03:13:44 +00:00
Ubuntu
18d19538d3 feat(ce): Sprint P — seed démo CE + tests invites + cleanup helper
All checks were successful
CI / test (pull_request) Successful in 2m40s
Seed plugin `demo-ce-seed` :
- Nouveau descriptor dans registry (category visual).
- src/lib/plugins/seeds/demo-ce.ts : seedDemoCe() + archiveDemoCe().
  Crée org « Comité ESA Kourou (démo) » approved=true + 2 CE_MANAGERs
  + 3 CE_MEMBERs (password "demo") + 2 carbets co-gérés
  (OrganizationCarbetMembership) + 1 RentalProvider org-scoped +
  4 items (hamac, moustiquaire, kayak, réchaud).
- Idempotent : check existence par slug/email avant create. Upsert
  pour users.
- Disable : soft-archive carbets (status=ARCHIVED), delete
  RentalProvider démo (best-effort si pas de booking), delete users
  démo (cascade memberships) + delete org.
- Branchement hooks onEnable/onDisable dans plugins/hooks.ts.

Permet de visualiser le module CE end-to-end sans signup manuel :
admin active le plugin → l'org démo et son écosystème apparaissent
sur le site (badge « Géré par le CE Comité ESA Kourou (démo) » sur
les fiches carbet, items rental dans le catalogue /materiel).

ce-invites.ts refactor :
- Exporte hashToken (déjà sha256, désormais documenté).
- Extrait isInviteValid(row, now=Date) : helper pur testable. La
  fonction getOrgInviteByToken le réutilise.

tests/lib/ce-invites.test.ts (9 cas) :
- hashToken : déterminisme, format sha256 64-hex, inputs différents,
  pas de fuite du plain.
- isInviteValid : non consommé+non expiré → vrai ; consommé → faux ;
  expiré → faux ; les 2 raisons → faux ; injection now pour tests
  temporels.

Total tests : 83/83 ✓ (74 précédents + 9 nouveaux).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 03:13:14 +00:00
eee052b2a8 feat(rental): Sprint O — reversements prestataires
All checks were successful
CI / test (push) Successful in 2m24s
2026-06-03 02:59:49 +00:00
Ubuntu
5be62f012f feat(rental): Sprint O — reversements prestataires (payouts)
All checks were successful
CI / test (pull_request) Successful in 2m40s
Marketplace encaisse centralisé sur System D → besoin de tracer les
virements mensuels aux prestataires tiers. Migration appliquée prod.

Schema :
- Modèle RentalPayoutMark { id, providerId, periodMonth, amount,
  reference, paidAt, paidByEmail }. Unique (providerId, periodMonth)
  → 1 mark = 1 mois = 1 virement par provider.

Lib src/lib/payouts.ts :
- monthKey(d) → 1er du mois minuit UTC (clé de période).
- listProviderPayouts({monthsBack=6}) → grid provider × mois avec
  bookingsCount + grossAmount (itemsTotal) + commission + netAmount
  (gross-commission) + statut paid via RentalPayoutMark. Exclut
  System D (commission 0%, géré par l'asso). Statut « payé » lu
  depuis les marks. Tri : mois desc puis providerName.
- createPayoutMark (idempotent via findUnique avant insert) +
  deletePayoutMark.

Politique : net dû = itemsTotal - commissionAmount (depositTotal
hors flux, collecté par le provider auprès du client). Politique
documentée dans le commentaire en tête de payouts.ts.

/admin/payouts/page.tsx :
- 3 KPIs (À payer / Déjà payé / Mois affichés).
- Une section par mois (6 derniers), tableau provider × CA brut +
  commission + net dû + statut.
- MarkPaidForm : bouton « Marquer payé » → form inline (amount
  pré-rempli avec net dû, reference optionnelle) → action
  markPayoutPaidAction. Statut payé montre amount + ref + bouton
  « Annuler marquage ».

Server actions :
- markPayoutPaidAction (admin only, idempotent, audit
  admin.payouts/payout.mark + payout.already_marked) → envoie
  sendPayoutSent au contactEmail du provider (best-effort).
- unmarkPayoutPaidAction → delete + audit payout.unmark.

Email sendPayoutSent : notification au provider quand un virement est
marqué payé. Inclut amount + reference + lien dashboard.

Sidebar admin gagne entrée « Reversements » sous Activité.

Tests vitest tests/lib/payouts.test.ts (4 cas) : monthKey
normalisation UTC + idempotence + janvier sans bug, formatMonth fr-FR.
Total : 74/74 ✓.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 02:59:16 +00:00
58fd65a4d0 feat(analytics): Sprint N — dashboards CE + admin
All checks were successful
CI / test (push) Successful in 2m25s
2026-06-03 02:46:29 +00:00
Ubuntu
0dc560385d feat(analytics): Sprint N — dashboards CE + admin
All checks were successful
CI / test (pull_request) Successful in 2m34s
src/lib/analytics.ts (NEW) — 3 queries server-only :
- getMonthlyRevenueSeries({organizationId?, monthsBack=12}) → 12
  buckets « YYYY-MM » avec carbetRevenue + rentalRevenue + total.
  Scope optionnel par org via memberships (Booking) et
  provider.organizationId (RentalBooking).
- getCarbetsOccupancy({organizationId?, monthsBack=3}) → liste triée
  par occupancyPct avec bookedNights/totalNights pour chaque carbet
  PUBLISHED (filtré par memberships si org).
- getAdminGlobalKpis() → users (total + breakdown par rôle),
  carbetsPublished, bookings/rentals 30j, revenue 30j, top 5 carbets
  + top 5 providers par CA 30j.

src/components/analytics/MonthlyRevenueChart.tsx (NEW) — bar chart
SVG simple (pas de lib externe), stack carbet + rental, grid Y, tooltips
via <title>, légende couleurs. Responsive overflow-x-auto.

/espace-ce/analytics/page.tsx (NEW) :
- 3 KPIs (CA 12 mois total / Carbet / Matériel)
- MonthlyRevenueChart scopé par org
- Liste taux d'occupation carbets 3 derniers mois (barres horizontales)
- Lien ajouté depuis le dashboard /espace-ce

/admin/analytics/page.tsx (NEW) :
- 4 KPIs (utilisateurs, carbets publiés, bookings 30j, CA 30j)
- Breakdown users par rôle (barres horizontales + pourcentages)
- Carte « Activité 30j » avec bookings carbet + locations matériel
- MonthlyRevenueChart global
- Top 5 carbets (CA 30j) + Top 5 prestataires rental (CA 30j)
- Sidebar admin gagne entrée « Analytics » sous « Vue d'ensemble »

Pas de nouvelle dépendance npm — graphiques en SVG natif.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 02:46:01 +00:00
73d24b70f7 feat(rental): Sprint M — refonds + annulations Stripe
All checks were successful
CI / test (push) Successful in 2m24s
2026-06-03 02:18:33 +00:00
Ubuntu
c564028ca9 feat(rental): Sprint M — refonds + annulations Stripe
All checks were successful
CI / test (pull_request) Successful in 2m37s
Politique de remboursement v1 :
- > 7 jours du début → FULL (location + caution)
- 1 à 7 jours → PARTIAL_50 (50% location + caution intégrale)
- < 24h ou passé → DEPOSIT_ONLY (caution seulement, pas de remboursement
  sur la location)

src/lib/rental-refund.ts (NEW) : computeRentalRefund({startDate,
itemsTotal, depositTotal}) → { itemsRefund, depositRefund, totalRefund,
policy, policyLabel }. Arrondi au centime, support de Decimal.

POST /api/rentals/[id]/cancel :
- Auth multi-rôle : tenant de la booking, RENTAL_PROVIDER nominal ou
  CE_MANAGER de l'org du provider, ADMIN. Détecte `cancelledBy` pour
  adapter l'email.
- Refuse si status ∉ {PENDING, CONFIRMED} (HANDED_OVER → non
  annulable, contacter Karbé).
- Calcule le refund selon la politique.
- Stripe refund best-effort si paymentStatus=SUCCEEDED + stripeSessionId
  existante + isStripeConfigured + totalRefund > 0. Retrieve session →
  payment_intent → refunds.create. Échec Stripe = audit-logged mais
  le flip status continue (l'asso pourra rembourser manuellement).
- Transaction : update RentalBooking (CANCELLED + paymentStatus
  REFUNDED si SUCCEEDED sinon FAILED) + delete RentalItemAvailability
  (libère stock).
- Audit log rental.cancel avec montants, policy, cancelledBy,
  stripeRefundId, stripeRefundError.
- Email best-effort : sendRentalCancelled à tenant + provider (sauf si
  provider est le canceller).

src/components/CancelRentalButton.tsx : composant client confirm dialog
inline avec textarea motif (max 500 chars). Branché sur :
- /mes-locations : « Annuler ma location » sur résa PENDING/CONFIRMED
- BookingDecision (utilisé par /espace-prestataire/reservations ET
  /espace-ce/materiel/reservations) : remplace l'ancienne mini-confirm
  qui flippait juste le status, désormais via la vraie API refund

sendRentalCancelled email : adapté selon cancelledBy ("Vous avez annulé"
/ "<Provider> a annulé" / "L'équipe Karbé a annulé").

tests/lib/rental-refund.test.ts : 8 cas (FULL @ 10+ et 7j, PARTIAL_50,
DEPOSIT_ONLY < 24h et passé, arrondi centime, zéro caution, policyLabel).
Total projet : 70/70 ✓.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 02:17:58 +00:00
7a12848b5b feat(ce): Sprint L — email auto invites + admin memberships UI
All checks were successful
CI / test (push) Successful in 2m25s
2026-06-03 01:59:53 +00:00
Ubuntu
3a557b6de5 feat(ce): Sprint L — email auto invites + admin memberships UI
All checks were successful
CI / test (pull_request) Successful in 2m39s
Email automatique pour les invites CE_MEMBER :
- sendCeInviteEmail(to, orgName, inviteUrl, inviterName?) : template
  best-effort (dry-run sans Resend), bouton CTA + lien direct en plain
  text. Mentionne TTL 14j + warning si pas le destinataire attendu.
- createInviteAction branche l'envoi automatique quand un email est
  renseigné dans le formulaire. Audit log gagne emailedAutomatically.
- InviteForm UI : affiche « lien généré · email envoyé » quand un
  email était fourni. Texte d'aide mis à jour.
- Sans email → comportement inchangé : lien à copier manuellement.

Admin /admin/carbets/[id] gagne section memberships :
- src/lib/admin/carbets.ts : getCarbetForEdit inclut organizations +
  listOrganizationsForLink helper (toutes orgs triées approved desc).
- 2 actions admin : linkCarbetToOrganizationAction (idempotent) +
  unlinkCarbetFromOrganizationAction. Audit scope=admin.carbets,
  events carbet.org.link / carbet.org.unlink.
- CarbetMemberships client component : liste les orgs liées (badge
  pending si org non approuvée) + select des orgs disponibles + boutons
  Lier/Délier. Désactive le select quand toutes les orgs sont déjà
  liées.

Le link admin permet de :
- Lier rétroactivement un carbet existant à un CE (cas où l'orga
  intègre un carbet d'un hôte individuel).
- Délier un carbet quand un CE part ou que le carbet repasse en
  gestion individuelle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 01:59:18 +00:00
2b8d786cf9 feat(ce): Sprint K — public badge + invites CE_MEMBER + tests
All checks were successful
CI / test (push) Successful in 2m26s
2026-06-03 00:03:38 +00:00
Ubuntu
ea0e606735 feat(ce): Sprint K — public badge + invites CE_MEMBER + tests
All checks were successful
CI / test (pull_request) Successful in 2m45s
Public badge sur fiche carbet :
- carbet-public.ts charge les OrganizationCarbetMembership (org
  approuvée uniquement) + expose `organizations: {id,name,slug}[]`.
- /carbets/[slug] affiche « Géré par le CE <name> » sous le header
  si au moins 1 org liée.

Invites CE_MEMBER :
- Migration 20260603300000_org_invite_token : OrgInviteToken
  (tokenHash, organizationId, email?, createdByUserId, expiresAt,
  usedAt). Cascade sur Organization. Index expiresAt + organizationId.
- src/lib/ce-invites.ts : createOrgInviteToken (TTL 14j),
  listOrgInviteTokens, getOrgInviteByToken (validité + expiry),
  markOrgInviteConsumed, revokeOrgInviteToken. Token = 24 bytes
  base64url, hash sha256.
- /espace-ce/membres : liste membres (CE_MANAGER + CE_MEMBER actifs)
  + form de génération de lien (email optionnel = lock email côté
  signup) + liste des invitations avec statut actif/consommé/expiré +
  bouton révoquer.
- /espace-ce/membres/actions.ts : createInviteAction +
  revokeInviteAction. Audit log scope=ce.invite.
- API /api/signup étendue : zod accepte inviteToken, branche dédiée
  qui crée User CE_MEMBER + organizationId du token + marquage
  usedAt. Vérif email match si email fourni dans le token.
- /inscription?invite=TOKEN : récupère l'invite, pré-affiche org name,
  lock email si fourni, masque les fieldsets type de compte (forcé
  CE_MEMBER).

CTA marketing :
- /pour-comites-entreprise : section CTA « Créer mon espace CE » sous
  le rendu content-pages, conditionnée par plugin ce-management.

Tests vitest (tests/lib/ce-access.test.ts) :
- canManageCarbet : admin always, owner direct, CE_MANAGER via org
  match, refus si autre org / pas d'org / TOURIST / pas de membership.
- 9 tests, mocks next-auth + @/auth + @/lib/authorization pour éviter
  next/server (incompatible vitest sans setup).
- Total tests projet : 62/62 ✓.

Dashboard /espace-ce : lien vers /espace-ce/membres en bas.

Migration prod appliquée.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 00:03:03 +00:00
ab1bbb5484 feat(ce): Sprint J — matériel rental côté CE
All checks were successful
CI / test (push) Successful in 2m19s
2026-06-02 23:48:30 +00:00
Ubuntu
caa3d5214f feat(ce): Sprint J — matériel rental côté CE
All checks were successful
CI / test (pull_request) Successful in 2m41s
src/lib/rental-access.ts CE-aware :
- requireRentalProviderSession accepte CE_MANAGER (en plus de
  RENTAL_PROVIDER et ADMIN).
- getCurrentRentalProvider : CE_MANAGER → findFirst par
  organizationId ; RENTAL_PROVIDER → par managedByUserId.
- getCurrentRentalProviderForCe(organizationId) helper explicite.
- canManageRentalProvider gagne un userOrgId? optionnel : vrai si
  manager nominal OU CE_MANAGER + provider.organizationId === userOrgId.
- Callers existants (5 sites : actions.ts + 4 routes API rental)
  passent désormais session.user.organizationId.

Actions /espace-prestataire/actions.ts role-aware :
- requireOwnedProvider() dérive basePath selon le rôle :
  CE_MANAGER → /espace-ce/materiel ; sinon → /espace-prestataire.
- Tous les redirect/revalidatePath utilisent basePath, donc
  createHostItemAction, updateHostItemAction, deleteHostItemAction,
  addItemBlockAction, removeItemBlockAction, updateBookingStatusAction
  emmènent le user vers son espace contextuel après chaque opération.

/espace-ce/materiel/page.tsx — onboarding :
- Plugin gear-rental disabled → message d'info.
- Pas de provider activé → CTA « Activer la location matériel pour
  <org> » (bouton bloqué si org pending, message bannière).
- Provider existant → dashboard avec KPIs (items actifs, résa pending,
  confirmées à venir, revenu 30j) + 2 ActionCards Items + Réservations.

actions.ts (CE) :
- activateRentalProviderForCeAction → crée RentalProvider(organizationId,
  name="Matériel <org>", managedByUserId=session.user.id, approved=true)
  + audit + redirect /espace-ce/materiel.

Pages CE clonées (réutilisent les composants, actions, helpers
existants — zéro duplication de logique métier) :
- /espace-ce/materiel/items/page.tsx (liste)
- /espace-ce/materiel/items/new/page.tsx (HostItemForm)
- /espace-ce/materiel/items/[itemId]/page.tsx (MediaUploader +
  HostItemForm + ItemBlocksManager + ItemInlineDelete)
- /espace-ce/materiel/reservations/page.tsx (BookingDecision)

Tous importent depuis /espace-prestataire/{actions, items, reservations}
pour rester DRY. Les breadcrumbs et links sont adaptés au contexte CE.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-02 23:47:57 +00:00
03b740dfff feat(ce): Sprint I — CRUD carbets côté CE
All checks were successful
CI / test (push) Successful in 2m26s
2026-06-02 23:34:48 +00:00
Ubuntu
74ea280f28 feat(ce): Sprint I — CRUD carbets côté CE
All checks were successful
CI / test (pull_request) Successful in 2m36s
Session étendue :
- Ajout session.user.organizationId (typedef + auth callbacks JWT
  & session). Permet à canManageCarbet de check membership sans
  refetch DB.

src/lib/carbet-access.ts :
- MANAGER_ROLES inclut désormais CE_MANAGER → /espace-hote ET /espace-ce
  sont gardés par requireOwnerSession (CE_MANAGER passe, OWNER passe,
  ADMIN passe).
- canManageCarbet(session, carbetOwnerId, linkedOrgIds=[]) :
  - ADMIN → toujours vrai
  - OWNER + session.user.id === carbetOwnerId → vrai
  - CE_MANAGER + session.user.organizationId ∈ linkedOrgIds → vrai
  - sinon faux.
- Callers historiques (qui ne passent pas linkedOrgIds) restent sûrs :
  CE_MANAGER ne peut rien gérer par défaut.

createCarbet étendu : si role=CE_MANAGER + organizationId présent,
crée OrganizationCarbetMembership dans la même transaction. Redirige
ensuite vers /espace-ce/carbets/[id] au lieu de /espace-hote/.

Sweep des callers canManageCarbet (8 sites) : chargent désormais
`Carbet.organizations` + passent linkedOrgIds. Includes :
- updateCarbet, setCarbetStatus, deleteCarbet, reorderMedia, deleteMedia
  dans espace-hote/carbets/actions.ts
- espace-hote/carbets/[carbetId]/page.tsx
- API POST /api/carbets/[carbetId]/media

Pages /espace-ce/carbets/* :
- page.tsx : liste les carbets co-gérés via OrganizationCarbetMembership,
  forms Publier/Dépublier/Supprimer pointent vers les actions
  existantes de /espace-hote (réutilisation totale)
- nouveau/page.tsx : requireApprovedOrg (redirect dashboard si pending),
  CarbetForm + createCarbet (même action que /espace-hote — détecte
  CE_MANAGER et crée membership)
- [carbetId]/page.tsx : vérif que le carbet est lié à l'org du user
  + MediaUploader + CarbetForm (updateCarbet partagé)

Dashboard /espace-ce/page.tsx : ActionCard « Mes carbets » devient
active (le lien marche même en pending — l'org peut préparer des
brouillons, c'est juste la publication qui est bloquée).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-02 23:34:17 +00:00
3d77632ba0 feat(ce): Sprint H — signup CE public + /espace-ce shell
All checks were successful
CI / test (push) Successful in 2m21s
2026-06-02 23:13:15 +00:00
Ubuntu
63a29d9ade feat(ce): Sprint H — signup CE public + /espace-ce shell
All checks were successful
CI / test (pull_request) Successful in 2m33s
src/lib/ce-access.ts (NEW) :
- requireCeManagerSession (redirect connexion ou / si rôle insuffisant)
- getCurrentCeOrganization (CE_MANAGER → son org via organizationId,
  ADMIN → org ciblée par paramètre ou null)
- canManageCarbetForCe (owner direct OU membre d'une org liée)
- requireApprovedOrg (redirect /espace-ce?pending=1 si non validée)

Emails best-effort :
- sendNewCeRequest → admin (contact@karbe) avec lien filtré
  /admin/organizations?status=pending
- sendCeApproved → CE_MANAGERs actifs de l'org après validation
- Branchement dans approveOrganizationAction : envoie le mail à tous
  les CE_MANAGERs actifs de l'org en best-effort.

Signup CE public :
- SignupForm 4e tuile « Comité d'Entreprise » avec champ orgName.
  Layout grid 4 colonnes sur lg, 2 sur sm.
- /api/signup étendu :
  - zod accepte CE_MANAGER + orgName
  - transaction $tx atomique : Organization (approved=false, slug
    auto-unique via slugify + suffix) + User (role=CE_MANAGER,
    organizationId lié)
  - sendNewCeRequest best-effort
  - réponse étendue avec organizationId
- Pattern slug : retry avec suffix -2, -3… jusqu'à libre

Dashboard /espace-ce :
- layout.tsx : requirePluginOr404("ce-management") +
  requireCeManagerSession
- page.tsx : 4 KPIs (carbets co-gérés, items rental, bookings 30j,
  revenu 30j), bannière « En attente de validation » si pending,
  2 ActionCards (Mes carbets, Matériel rental) marquées « Bientôt »
  jusqu'aux sprints I et J
- ce-dashboard.ts : getCeOrgKpis (agrège bookings carbets via
  membership + rentalBookings via provider.organizationId) +
  listCeCarbets pour Sprint I

SiteHeader : lien « Espace CE » conditionné par role + plugin
(mirror du lien Espace prestataire).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-02 23:12:46 +00:00
8609c3c98b feat(ce): Sprint G — data model + admin validation
All checks were successful
CI / test (push) Successful in 2m19s
2026-06-02 22:56:28 +00:00
Ubuntu
946dd8d5d2 feat(ce): Sprint G — data model + admin validation
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>
2026-06-02 22:55:54 +00:00
d24e3b4af7 feat(rental): Sprint F — photos & vidéos items rental
All checks were successful
CI / test (push) Successful in 2m39s
2026-06-02 09:38:38 +00:00
Ubuntu
9da58288dc feat(rental): Sprint F — photos & vidéos items rental
All checks were successful
CI / test (pull_request) Successful in 2m18s
Nouveau modèle `RentalItemMedia` parallèle de `Media` (carbet) :
- s3Key / s3Url / sortOrder / type (PHOTO|VIDEO), cascade sur RentalItem
- Migration `20260603100000_rental_item_media` appliquée

Endpoints upload dédiés (mêmes conventions que carbet) :
- POST /api/uploads/rental-presign + POST /api/uploads/rental-finalize
  → auth par canManageRentalProvider (admin OR provider manager)
  → s3Key préfixé `rental-items/<itemId>/`
  → finalize hydrate `RentalItem.imageUrl` avec la première PHOTO
  → générateur de variantes (320/800/1600 via sharp) en best-effort
- DELETE /api/rental-media/[id] + POST /api/rental-media/reorder
  → reorder rafraîchit imageUrl (cover = sortOrder 0)

`MediaUploader` rendu générique :
- Nouveau prop `scope: {kind: "carbet" | "rental-item", id}` ; conserve
  rétro-compat `carbetId` (deprecated)
- Endpoints + payload key (`carbetId` ↔ `itemId`) calculés via
  `endpointsFor()`. Aucun changement de comportement côté carbet.

UI branchée :
- /admin/rental-items/[id] : section « Photos & vidéos » au-dessus du
  form, alimentée par `item.media` chargé par `getRentalItemForAdmin`
- /espace-prestataire/items/[itemId] : idem, charge via `getHostItem`
- /materiel/[itemId] : nouveau `<ItemGallery />` (thumbs cliquables +
  support vidéo). Fallback : ancien `item.imageUrl` si pas de média
  dédié (compat seed).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-02 09:34:09 +00:00
d42584cc4c fix(rental): no setState in effect for cart hydration
All checks were successful
CI / test (push) Successful in 2m15s
2026-06-02 08:54:59 +00:00
Ubuntu
15f41a7e2a fix(rental): no setState in effect for cart hydration
All checks were successful
CI / test (pull_request) Successful in 2m21s
ESLint react-hooks/set-state-in-effect bloque le CI. On déplace la
re-hydratation depuis le cookie dans le lazy initializer de useState
(qui ne court qu'une fois côté client). Conserve la cohérence si un
autre onglet a modifié le panier entre le render serveur et l'hydration,
sans déclencher de re-render en cascade.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-02 08:54:39 +00:00
740e9958aa feat(rental): Sprint E — emails + plugin toggle + tests
Some checks failed
CI / test (push) Failing after 1m10s
2026-06-02 08:50:15 +00:00
Ubuntu
5607a51980 feat(rental): Sprint E — emails + plugin toggle + tests
Some checks failed
CI / test (pull_request) Failing after 1m10s
3 nouveaux templates email (best-effort, dry-run sans Resend) :
- sendRentalRequestedTenant : récap de demande au locataire (par RB)
- sendRentalRequestedProvider : nouvelle demande au prestataire
- sendRentalConfirmed : confirmation paiement reçu

Branchements :
- POST /api/rentals/checkout : envoie tenant + provider après création
  des RentalBooking (PENDING), catch global pour ne pas bloquer
- Webhook Stripe rental-bundle : envoie sendRentalConfirmed à chaque
  locataire après update CONFIRMED+SUCCEEDED

Plugin gear-rental :
- Ajout au registry (catégorie business)
- layout.tsx /materiel + /espace-prestataire avec requirePluginOr404
- requirePluginOr404 dans /panier et /mes-locations
- isPluginEnabled guard dans POST /api/rentals/checkout (404 si off)
- SiteHeader masque liens Matériel / Mes locations / Espace prestataire
  + CartBadge si plugin désactivé
- CompleteYourStay renvoie null si plugin désactivé
Décision admin → activable depuis /admin/plugins comme tous les autres.

Tests vitest (tests/lib/rentals.test.ts, 16 tests) :
- diffDays (mêmes dates, 1 nuit, 7 jours, négatif)
- parseCart (null/garbage/schéma invalide/valide/format date)
- serializeCart (updatedAt, roundtrip)
- commission formula (0%, 15%, arrondi centime)
- availability arithmetic (totalQty libre, soustractions, plancher 0)

53 tests pass total. Build OK.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-02 08:49:39 +00:00
0723e50189 feat(rental): Sprint D — panier + checkout + carbet integration
Some checks failed
CI / test (push) Failing after 1m9s
2026-06-02 08:44:26 +00:00
Ubuntu
91b4d918ea feat(rental): Sprint D — panier + checkout + intégration carbet
Some checks failed
CI / test (pull_request) Failing after 1m8s
Cart lib + cookie persistence (karbe-rental-cart, 30j) avec context React
useCart(). Provider wrappé dans layout pour hydratation server→client.

Page /panier :
- Récap regroupé par prestataire (sous-totaux, caution)
- Édition lignes (dates, qté), suppression, vider panier
- Bouton « Valider et payer » → POST /api/rentals/checkout
- Badge 🛒 dans SiteHeader avec total items

Composant <AddToCart /> sur /materiel/[itemId] avec date picker + qté.

API POST /api/rentals/checkout :
- Validation auth + items actifs + provider approved + qté/dates
- Transaction Prisma : recheck stock par fenêtre + crée 1 RentalBooking
  par prestataire + RentalLines (snapshot prix) + RentalItemAvailability
  (blocage des dispos)
- Calcul commissionAmount selon provider.commissionPct
- Si Stripe activé : Checkout Session unique avec 1 line_item par
  RentalBooking, metadata {type:"rental-bundle", rentalBookingIds:[]}
- Sinon : crée en PENDING, retourne rentalBookingIds
- Vide le cookie panier après création
- Audit log rental.checkout.created

Webhook Stripe étendu :
- checkout.session.completed type=rental-bundle → CONFIRMED+SUCCEEDED
  sur toutes les RentalBookings du bundle
- payment_intent.payment_failed metadata.rentalBookingIds → CANCELLED
  + supprime les RentalItemAvailability (libère le stock)

Intégration carbet :
- /carbets/[slug] : panneau « Compléter votre séjour » avec items des
  prestataires de la même rivière + System D (recommandation contextuelle)
- /reservations/[id] : section « Matériel associé » listant les
  RentalBookings liées
- /mes-locations : page récap toutes les locations (System D + tiers,
  liées carbet ou standalone)
- Lien « Mes locations » dans SiteHeader

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-02 08:41:53 +00:00
1165f32a63 Merge pull request 'feat(rental): Sprint C — espace prestataire' (#76) from feat/rental-sprint-c into main
All checks were successful
CI / test (push) Successful in 2m18s
2026-06-02 08:01:44 +00:00
Claude Integration
59786e5365 feat(rental): Sprint C — espace prestataire (signup+dashboard+items+calendrier+résa)
All checks were successful
CI / test (pull_request) Successful in 2m33s
2026-06-02 08:01:42 +00:00
8d7e9cfdc2 Merge pull request 'feat(rental): Sprint B — catalogue public' (#75) from feat/rental-sprint-b into main
All checks were successful
CI / test (push) Successful in 2m14s
2026-06-02 07:49:46 +00:00
Claude Integration
f31fb8a32c feat(rental): Sprint B — catalogue public /materiel + détail item + dispo + nav
All checks were successful
CI / test (pull_request) Successful in 2m28s
2026-06-02 07:49:43 +00:00
1dd2d65626 Merge pull request 'fix(rental): client/server import boundary' (#74) from fix/rental-client-server-boundary into main
All checks were successful
CI / test (push) Successful in 2m11s
2026-06-02 03:31:26 +00:00
114 changed files with 9963 additions and 97 deletions

View file

@ -0,0 +1,22 @@
-- Sprint F : RentalItemMedia (photos & vidéos pour items rental).
-- Mêmes conventions que Media (carbet) : MediaType enum existant, s3Key/s3Url,
-- sortOrder pour cover (0). Cascade sur RentalItem.
CREATE TABLE "RentalItemMedia" (
"id" TEXT NOT NULL,
"itemId" TEXT NOT NULL,
"type" "MediaType" NOT NULL,
"s3Key" TEXT NOT NULL,
"s3Url" TEXT NOT NULL,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RentalItemMedia_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "RentalItemMedia_itemId_sortOrder_idx"
ON "RentalItemMedia"("itemId", "sortOrder");
ALTER TABLE "RentalItemMedia"
ADD CONSTRAINT "RentalItemMedia_itemId_fkey"
FOREIGN KEY ("itemId") REFERENCES "RentalItem"("id")
ON DELETE CASCADE ON UPDATE CASCADE;

View 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;

View file

@ -0,0 +1,22 @@
-- Sprint K : tokens d'invitation CE_MEMBER.
-- Le CE_MANAGER génère un lien /inscription?invite=TOKEN, le destinataire s'inscrit
-- automatiquement comme CE_MEMBER de l'organisation. usedAt à la consommation.
CREATE TABLE "OrgInviteToken" (
"tokenHash" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"email" TEXT,
"createdByUserId" TEXT,
"expiresAt" TIMESTAMP(3) NOT NULL,
"usedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "OrgInviteToken_pkey" PRIMARY KEY ("tokenHash")
);
CREATE INDEX "OrgInviteToken_organizationId_idx" ON "OrgInviteToken"("organizationId");
CREATE INDEX "OrgInviteToken_expiresAt_idx" ON "OrgInviteToken"("expiresAt");
ALTER TABLE "OrgInviteToken"
ADD CONSTRAINT "OrgInviteToken_organizationId_fkey"
FOREIGN KEY ("organizationId") REFERENCES "Organization"("id")
ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -0,0 +1,28 @@
-- Sprint O : reversements prestataires.
-- RentalPayoutMark trace les virements bancaires manuels effectués par System D
-- vers les RentalProvider tiers (le marketplace encaisse centralisé, redistribue
-- hors plateforme une fois par mois). Unique (provider, mois) pour empêcher
-- les marquages en doublon.
CREATE TABLE "RentalPayoutMark" (
"id" TEXT NOT NULL,
"providerId" TEXT NOT NULL,
"periodMonth" TIMESTAMP(3) NOT NULL,
"amount" DECIMAL(10, 2) NOT NULL,
"reference" TEXT,
"paidAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"paidByEmail" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RentalPayoutMark_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "RentalPayoutMark_providerId_periodMonth_key"
ON "RentalPayoutMark"("providerId", "periodMonth");
CREATE INDEX "RentalPayoutMark_periodMonth_idx"
ON "RentalPayoutMark"("periodMonth");
ALTER TABLE "RentalPayoutMark"
ADD CONSTRAINT "RentalPayoutMark_providerId_fkey"
FOREIGN KEY ("providerId") REFERENCES "RentalProvider"("id")
ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -72,16 +72,59 @@ 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[]
invites OrgInviteToken[]
@@index([name])
@@index([approved])
}
/// Token d'invitation pour rejoindre une organisation comme CE_MEMBER.
/// Le CE_MANAGER génère un lien, le destinataire s'inscrit via /inscription?invite=TOKEN.
/// Pas de unique sur email pour permettre plusieurs invites pendants par destinataire.
model OrgInviteToken {
tokenHash String @id
organizationId String
email String?
createdByUserId String?
expiresAt DateTime
usedAt DateTime?
createdAt DateTime @default(now())
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
@@index([organizationId])
@@index([expiresAt])
}
/// 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 +200,7 @@ model Carbet {
bookings Booking[]
reviews Review[]
subscriptions Subscription[]
organizations OrganizationCarbetMembership[]
@@index([ownerId])
@@index([status])
@ -425,6 +469,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 +485,36 @@ 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[]
payoutMarks RentalPayoutMark[]
@@index([active, approved])
@@index([managedByUserId])
@@index([organizationId])
}
/// Trace les reversements bancaires manuels (System D paie le provider hors plateforme).
/// La période est représentée par le mois (1er du mois minuit UTC) ; unique par
/// (provider, période) pour empêcher de marquer 2 fois le même mois.
model RentalPayoutMark {
id String @id @default(cuid())
providerId String
/// 1er du mois minuit UTC — sert de clé de période.
periodMonth DateTime
/// Montant effectivement viré au provider, en euros.
amount Decimal @db.Decimal(10, 2)
/// Référence de virement (optionnelle, à coller depuis la banque).
reference String?
paidAt DateTime @default(now())
paidByEmail String?
createdAt DateTime @default(now())
provider RentalProvider @relation(fields: [providerId], references: [id], onDelete: Cascade)
@@unique([providerId, periodMonth])
@@index([periodMonth])
}
model RentalItem {
@ -466,11 +538,26 @@ model RentalItem {
provider RentalProvider @relation(fields: [providerId], references: [id], onDelete: Cascade)
availabilities RentalItemAvailability[]
lines RentalLine[]
media RentalItemMedia[]
@@index([providerId])
@@index([category, active])
}
model RentalItemMedia {
id String @id @default(cuid())
itemId String
type MediaType
s3Key String
s3Url String
sortOrder Int @default(0)
createdAt DateTime @default(now())
item RentalItem @relation(fields: [itemId], references: [id], onDelete: Cascade)
@@index([itemId, sortOrder])
}
model RentalItemAvailability {
id String @id @default(cuid())
itemId String

View file

@ -0,0 +1,169 @@
import Link from "next/link";
import { MonthlyRevenueChart } from "@/components/analytics/MonthlyRevenueChart";
import { getAdminGlobalKpis, getMonthlyRevenueSeries } from "@/lib/analytics";
export const dynamic = "force-dynamic";
export const metadata = { title: "Analytics globaux — Karbé admin" };
const ROLE_LABEL: Record<string, string> = {
ADMIN: "Admin",
OWNER: "Hôte",
RENTAL_PROVIDER: "Loueur matériel",
CE_MANAGER: "CE Manager",
CE_MEMBER: "CE Membre",
TOURIST: "Voyageur",
};
function fmtEur(n: number): string {
return n.toLocaleString("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 });
}
export default async function AdminAnalyticsPage() {
const [kpis, series] = await Promise.all([
getAdminGlobalKpis(),
getMonthlyRevenueSeries({ monthsBack: 12 }),
]);
return (
<div className="mx-auto max-w-6xl space-y-6">
<header className="mt-2">
<h1 className="text-2xl font-semibold text-zinc-900">Analytics globaux</h1>
<p className="mt-1 text-sm text-zinc-500">
Vue d&apos;ensemble plateforme : utilisateurs, activité 30 derniers jours, top performers.
</p>
</header>
<section className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<KpiCard label="Utilisateurs" value={kpis.usersTotal} />
<KpiCard label="Carbets publiés" value={kpis.carbetsPublished} />
<KpiCard label="Bookings 30j" value={kpis.bookings30d} />
<KpiCard label="CA 30j" value={fmtEur(kpis.revenue30d)} />
</section>
<section className="grid gap-4 lg:grid-cols-2">
<div className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">
Utilisateurs par rôle
</h2>
{kpis.usersTotal === 0 ? (
<p className="text-sm text-zinc-500">Aucun utilisateur.</p>
) : (
<ul className="space-y-1.5 text-sm">
{Object.entries(kpis.usersByRole)
.sort((a, b) => b[1] - a[1])
.map(([role, count]) => {
const pct = Math.round((count / kpis.usersTotal) * 100);
return (
<li key={role}>
<div className="flex items-baseline justify-between">
<span className="text-zinc-700">{ROLE_LABEL[role] ?? role}</span>
<span className="font-mono text-xs text-zinc-700">
{count} ({pct}%)
</span>
</div>
<div className="mt-0.5 h-1.5 overflow-hidden rounded-full bg-zinc-100">
<div
className="h-full bg-emerald-500"
style={{ width: `${pct}%` }}
/>
</div>
</li>
);
})}
</ul>
)}
</div>
<div className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">
Activité 30 derniers jours
</h2>
<ul className="space-y-2 text-sm">
<li className="flex items-baseline justify-between">
<span className="text-zinc-700">Bookings carbet</span>
<span className="font-mono font-semibold text-zinc-900">{kpis.bookings30d}</span>
</li>
<li className="flex items-baseline justify-between">
<span className="text-zinc-700">Locations matériel</span>
<span className="font-mono font-semibold text-zinc-900">{kpis.rentals30d}</span>
</li>
<li className="flex items-baseline justify-between border-t border-zinc-100 pt-2">
<span className="font-semibold text-zinc-900">Total CA 30j</span>
<span className="font-mono font-semibold text-emerald-700">
{fmtEur(kpis.revenue30d)}
</span>
</li>
</ul>
</div>
</section>
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">
Chiffre d&apos;affaires mensuel
</h2>
<MonthlyRevenueChart data={series} />
</section>
<section className="grid gap-4 lg:grid-cols-2">
<div className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">
Top carbets (30j)
</h2>
{kpis.topCarbets.length === 0 ? (
<p className="text-sm text-zinc-500">Aucune réservation sur les 30 derniers jours.</p>
) : (
<ul className="space-y-2 text-sm">
{kpis.topCarbets.map((c, i) => (
<li key={c.carbetId} className="flex items-baseline justify-between">
<span>
<span className="mr-2 text-xs text-zinc-500">#{i + 1}</span>
<Link href={`/admin/carbets/${c.carbetId}`} className="text-zinc-900 hover:underline">
{c.title}
</Link>
</span>
<span className="font-mono text-zinc-700">{fmtEur(c.revenue)}</span>
</li>
))}
</ul>
)}
</div>
<div className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">
Top prestataires rental (30j)
</h2>
{kpis.topProviders.length === 0 ? (
<p className="text-sm text-zinc-500">Aucune location sur les 30 derniers jours.</p>
) : (
<ul className="space-y-2 text-sm">
{kpis.topProviders.map((p, i) => (
<li key={p.providerId} className="flex items-baseline justify-between">
<span>
<span className="mr-2 text-xs text-zinc-500">#{i + 1}</span>
<Link
href={`/admin/rental-providers/${p.providerId}`}
className="text-zinc-900 hover:underline"
>
{p.name}
</Link>
</span>
<span className="font-mono text-zinc-700">{fmtEur(p.revenue)}</span>
</li>
))}
</ul>
)}
</div>
</section>
</div>
);
}
function KpiCard({ label, value }: { label: string; value: string | number }) {
return (
<div className="rounded-lg border border-zinc-200 bg-white px-4 py-3 shadow-sm">
<div className="text-[10px] uppercase tracking-wider text-zinc-500">{label}</div>
<div className="mt-1 text-2xl font-semibold text-zinc-900 font-mono">{value}</div>
</div>
);
}

View file

@ -0,0 +1,125 @@
"use client";
import { useState, useTransition } from "react";
type Org = { id: string; name: string; slug: string; approved: boolean };
type LinkedOrg = Org & { addedAt: Date };
type Props = {
carbetId: string;
linked: LinkedOrg[];
available: Org[];
linkAction: (carbetId: string, orgId: string) => Promise<{ ok: true; alreadyLinked: boolean } | { ok: false; error?: string }>;
unlinkAction: (carbetId: string, orgId: string) => Promise<{ ok: true } | { ok: false; error?: string }>;
};
export function CarbetMemberships({
carbetId,
linked,
available,
linkAction,
unlinkAction,
}: Props) {
const [pending, startTransition] = useTransition();
const [selectedOrgId, setSelectedOrgId] = useState("");
const [error, setError] = useState<string | null>(null);
// Filtre les orgs non encore liées
const linkedIds = new Set(linked.map((l) => l.id));
const options = available.filter((o) => !linkedIds.has(o.id));
function link() {
if (!selectedOrgId) return;
setError(null);
startTransition(async () => {
const res = await linkAction(carbetId, selectedOrgId);
if (!res.ok) setError(res.error || "Échec de la liaison");
else setSelectedOrgId("");
});
}
function unlink(orgId: string) {
setError(null);
startTransition(async () => {
const res = await unlinkAction(carbetId, orgId);
if (!res.ok) setError(res.error || "Échec");
});
}
return (
<div className="space-y-3">
{linked.length === 0 ? (
<p className="text-sm text-zinc-500">
Aucune organisation liée. Le carbet est géré uniquement par son propriétaire individuel.
</p>
) : (
<ul className="divide-y divide-zinc-100 rounded-md border border-zinc-200 bg-white">
{linked.map((o) => (
<li
key={o.id}
className="flex items-center justify-between gap-3 px-3 py-2 text-sm"
>
<div>
<span className="font-medium text-zinc-900">{o.name}</span>
<span className="ml-2 text-[11px] text-zinc-500">/{o.slug}</span>
{!o.approved ? (
<span className="ml-2 rounded-full bg-amber-100 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-amber-800 ring-1 ring-inset ring-amber-300">
Pending
</span>
) : null}
</div>
<button
type="button"
disabled={pending}
onClick={() => unlink(o.id)}
className="rounded border border-rose-200 bg-white px-2 py-1 text-[11px] text-rose-700 hover:bg-rose-50 disabled:opacity-60"
>
Délier
</button>
</li>
))}
</ul>
)}
{options.length > 0 ? (
<div className="flex flex-wrap items-center gap-2">
<select
value={selectedOrgId}
onChange={(e) => setSelectedOrgId(e.target.value)}
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=""> Choisir une organisation à lier </option>
{options.map((o) => (
<option key={o.id} value={o.id}>
{o.name} {o.approved ? "" : "(pending)"}
</option>
))}
</select>
<button
type="button"
disabled={pending || !selectedOrgId}
onClick={link}
className="rounded-md bg-emerald-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
>
{pending ? "…" : "Lier"}
</button>
</div>
) : (
<p className="text-[11px] text-zinc-500">
Toutes les organisations existantes sont déjà liées à ce carbet.
</p>
)}
{error ? (
<div className="rounded border border-rose-200 bg-rose-50 px-3 py-2 text-xs text-rose-700">
{error}
</div>
) : null}
<p className="text-[11px] text-zinc-500">
Une organisation liée signifie que ses CE_MANAGERs peuvent éditer ce carbet en plus du
propriétaire nominal.
</p>
</div>
);
}

View file

@ -1,15 +1,23 @@
import { notFound } from "next/navigation";
import Link from "next/link";
import { MediaUploader } from "@/components/MediaUploader";
import { StatusBadge } from "@/components/admin/StatusBadge";
import {
getCarbetForEdit,
listOrganizationsForLink,
listOwners,
listPirogueProviders,
} from "@/lib/admin/carbets";
import { CarbetForm } from "../_components/CarbetForm";
import { StatusBadge } from "@/components/admin/StatusBadge";
import { MediaUploader } from "@/components/MediaUploader";
import {
linkCarbetToOrganizationAction,
unlinkCarbetFromOrganizationAction,
updateCarbetAction,
} from "../actions";
import { CarbetMemberships } from "./_components/CarbetMemberships";
import { StatusActions } from "./_components/StatusActions";
import { updateCarbetAction } from "../actions";
export const dynamic = "force-dynamic";
@ -17,10 +25,11 @@ type PageProps = { params: Promise<{ id: string }> };
export default async function EditCarbetPage({ params }: PageProps) {
const { id } = await params;
const [carbet, owners, providers] = await Promise.all([
const [carbet, owners, providers, organizations] = await Promise.all([
getCarbetForEdit(id),
listOwners(),
listPirogueProviders(),
listOrganizationsForLink(),
]);
if (!carbet) notFound();
@ -28,6 +37,14 @@ export default async function EditCarbetPage({ params }: PageProps) {
"use server";
return await updateCarbetAction(id, fd);
};
const linkThis = async (carbetId: string, orgId: string) => {
"use server";
return await linkCarbetToOrganizationAction(carbetId, orgId);
};
const unlinkThis = async (carbetId: string, orgId: string) => {
"use server";
return await unlinkCarbetFromOrganizationAction(carbetId, orgId);
};
return (
<div className="mx-auto max-w-5xl space-y-6">
@ -61,6 +78,25 @@ export default async function EditCarbetPage({ params }: PageProps) {
</div>
</header>
<section className="mb-6 rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">
Organisations co-gestionnaires (CE)
</h2>
<CarbetMemberships
carbetId={carbet.id}
linked={carbet.organizations.map((m) => ({
id: m.organization.id,
name: m.organization.name,
slug: m.organization.slug,
approved: m.organization.approved,
addedAt: m.addedAt,
}))}
available={organizations}
linkAction={linkThis}
unlinkAction={unlinkThis}
/>
</section>
<section className="mb-6 rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">
Médias

View file

@ -213,6 +213,42 @@ export async function reorderMediaAction(carbetId: string, mediaId: string, dire
return { ok: true as const };
}
export async function linkCarbetToOrganizationAction(carbetId: string, organizationId: string) {
await requireRole([UserRole.ADMIN]);
const session = await auth();
const actorEmail = session?.user?.email ?? null;
// findFirst pour idempotence : si déjà lié, on ne touche pas + on ne crash pas.
const existing = await prisma.organizationCarbetMembership.findUnique({
where: { organizationId_carbetId: { organizationId, carbetId } },
select: { organizationId: true },
});
if (existing) {
return { ok: true as const, alreadyLinked: true };
}
await prisma.organizationCarbetMembership.create({
data: {
organizationId,
carbetId,
addedByUserId: session?.user?.id ?? null,
},
});
await audit("carbet.org.link", carbetId, actorEmail, { organizationId });
revalidatePath(`/admin/carbets/${carbetId}`);
return { ok: true as const, alreadyLinked: false };
}
export async function unlinkCarbetFromOrganizationAction(carbetId: string, organizationId: string) {
await requireRole([UserRole.ADMIN]);
const session = await auth();
const actorEmail = session?.user?.email ?? null;
await prisma.organizationCarbetMembership
.delete({ where: { organizationId_carbetId: { organizationId, carbetId } } })
.catch(() => {});
await audit("carbet.org.unlink", carbetId, actorEmail, { organizationId });
revalidatePath(`/admin/carbets/${carbetId}`);
return { ok: true as const };
}
async function audit(
event: string,
entityId: string,

View file

@ -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>
);
}

View file

@ -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">

View file

@ -5,7 +5,9 @@ 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 { sendCeApproved } from "@/lib/email";
import { prisma } from "@/lib/prisma";
import { recordAudit } from "@/lib/admin/audit";
@ -75,6 +77,38 @@ 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, {});
// Notifier les CE_MANAGERs de l'org : leur compte vient d'être débloqué.
try {
const data = await prisma.organization.findUnique({
where: { id },
select: {
name: true,
members: {
where: { role: UserRole.CE_MANAGER, isActive: true },
select: { email: true, firstName: true },
},
},
});
for (const m of data?.members ?? []) {
await sendCeApproved(m.email, m.firstName, data?.name ?? "");
}
} catch (e) {
console.error("[admin.org.approve] email send failed:", e instanceof Error ? e.message : e);
}
}
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();

View file

@ -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>

View file

@ -0,0 +1,126 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import type { ProviderPayout } from "@/lib/payouts";
type Props = {
payout: ProviderPayout;
markAction: (
providerId: string,
periodMonthISO: string,
fd: FormData,
) => Promise<{ ok: false; error: string } | { ok: true; alreadyExists: boolean }>;
unmarkAction: (providerId: string, periodMonthISO: string) => Promise<void>;
};
function fmtEur(n: number): string {
return n.toLocaleString("fr-FR", { style: "currency", currency: "EUR" });
}
export function MarkPaidForm({ payout, markAction, unmarkAction }: Props) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [opened, setOpened] = useState(false);
const [error, setError] = useState<string | null>(null);
function onSubmit(fd: FormData) {
setError(null);
startTransition(async () => {
const res = await markAction(payout.providerId, payout.periodMonth.toISOString(), fd);
if (!res.ok) {
setError(res.error);
return;
}
setOpened(false);
router.refresh();
});
}
function onUnmark() {
startTransition(async () => {
await unmarkAction(payout.providerId, payout.periodMonth.toISOString());
router.refresh();
});
}
if (payout.paid) {
return (
<div className="flex flex-col items-end gap-1 text-right">
<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">
Payé {fmtEur(payout.paid.amount)}
</span>
{payout.paid.reference ? (
<span className="font-mono text-[10px] text-zinc-500">Ref : {payout.paid.reference}</span>
) : null}
<button
type="button"
onClick={onUnmark}
disabled={pending}
className="text-[10px] text-zinc-500 hover:text-rose-700"
>
Annuler marquage
</button>
</div>
);
}
if (payout.netAmount <= 0) {
return <span className="text-[11px] text-zinc-400"></span>;
}
if (!opened) {
return (
<button
type="button"
onClick={() => setOpened(true)}
className="rounded-md bg-emerald-600 px-2.5 py-1 text-[11px] font-semibold text-white hover:bg-emerald-700"
>
Marquer payé
</button>
);
}
return (
<form action={onSubmit} className="flex flex-col items-end gap-1 rounded-md border border-emerald-200 bg-emerald-50/50 p-2">
<input
type="number"
name="amount"
step="0.01"
min={0}
defaultValue={payout.netAmount.toFixed(2)}
className="w-24 rounded border border-zinc-300 px-1.5 py-0.5 text-[11px]"
required
/>
<input
type="text"
name="reference"
placeholder="Réf. virement"
maxLength={100}
className="w-32 rounded border border-zinc-300 px-1.5 py-0.5 text-[11px]"
/>
{error ? <span className="text-[10px] text-rose-700">{error}</span> : null}
<div className="flex gap-1">
<button
type="button"
onClick={() => {
setOpened(false);
setError(null);
}}
disabled={pending}
className="text-[10px] text-zinc-500 hover:text-zinc-900"
>
Annuler
</button>
<button
type="submit"
disabled={pending}
className="rounded bg-emerald-600 px-2 py-0.5 text-[10px] font-semibold text-white hover:bg-emerald-700"
>
{pending ? "…" : "Confirmer"}
</button>
</div>
</form>
);
}

View file

@ -0,0 +1,96 @@
"use server";
import { revalidatePath } from "next/cache";
import { auth } from "@/auth";
import { UserRole } from "@/generated/prisma/enums";
import { recordAudit } from "@/lib/admin/audit";
import { requireRole } from "@/lib/authorization";
import {
createPayoutMark,
deletePayoutMark,
} from "@/lib/payouts";
import { prisma } from "@/lib/prisma";
import { sendPayoutSent } from "@/lib/email";
export async function markPayoutPaidAction(
providerId: string,
periodMonthISO: string,
fd: FormData,
): Promise<{ ok: false; error: string } | { ok: true; alreadyExists: boolean }> {
await requireRole([UserRole.ADMIN]);
const session = await auth();
const actor = session?.user?.email ?? null;
const amount = Number(fd.get("amount") ?? 0);
const reference = ((fd.get("reference") as string | null) ?? "").trim() || null;
if (!Number.isFinite(amount) || amount < 0) {
return { ok: false, error: "Montant invalide." };
}
const periodMonth = new Date(periodMonthISO);
if (Number.isNaN(periodMonth.getTime())) {
return { ok: false, error: "Période invalide." };
}
const res = await createPayoutMark({
providerId,
periodMonth,
amount,
reference,
paidByEmail: actor,
});
if (!res.ok) return res;
await recordAudit({
scope: "admin.payouts",
event: res.alreadyExists ? "payout.already_marked" : "payout.mark",
target: providerId,
actorEmail: actor,
details: {
periodMonth: periodMonth.toISOString().slice(0, 7),
amount,
reference,
},
});
// Notif provider best-effort (n'envoie que si on a un contactEmail)
if (!res.alreadyExists) {
try {
const provider = await prisma.rentalProvider.findUnique({
where: { id: providerId },
select: { name: true, contactEmail: true },
});
if (provider?.contactEmail) {
await sendPayoutSent(
provider.contactEmail,
provider.name,
periodMonth,
amount.toFixed(2),
reference,
);
}
} catch (e) {
console.error("[payouts] email send failed:", e instanceof Error ? e.message : e);
}
}
revalidatePath("/admin/payouts");
return { ok: true, alreadyExists: res.alreadyExists };
}
export async function unmarkPayoutPaidAction(providerId: string, periodMonthISO: string) {
await requireRole([UserRole.ADMIN]);
const session = await auth();
const actor = session?.user?.email ?? null;
const periodMonth = new Date(periodMonthISO);
if (Number.isNaN(periodMonth.getTime())) return;
await deletePayoutMark(providerId, periodMonth);
await recordAudit({
scope: "admin.payouts",
event: "payout.unmark",
target: providerId,
actorEmail: actor,
details: { periodMonth: periodMonth.toISOString().slice(0, 7) },
});
revalidatePath("/admin/payouts");
}

View file

@ -0,0 +1,155 @@
import Link from "next/link";
import { formatMonth, listProviderPayouts } from "@/lib/payouts";
import { markPayoutPaidAction, unmarkPayoutPaidAction } from "./actions";
import { MarkPaidForm } from "./_components/MarkPaidForm";
export const dynamic = "force-dynamic";
export const metadata = { title: "Reversements prestataires — Karbé admin" };
function fmtEur(n: number): string {
return n.toLocaleString("fr-FR", { style: "currency", currency: "EUR" });
}
export default async function PayoutsAdminPage() {
const payouts = await listProviderPayouts({ monthsBack: 6 });
// Group by month
const byMonth = new Map<number, typeof payouts>();
for (const p of payouts) {
const k = p.periodMonth.getTime();
if (!byMonth.has(k)) byMonth.set(k, []);
byMonth.get(k)!.push(p);
}
// Globals
const totalDue = payouts
.filter((p) => !p.paid && p.netAmount > 0)
.reduce((s, p) => s + p.netAmount, 0);
const totalPaid = payouts
.filter((p) => p.paid)
.reduce((s, p) => s + (p.paid!.amount), 0);
return (
<div className="mx-auto max-w-6xl space-y-6">
<header className="mt-2">
<h1 className="text-2xl font-semibold text-zinc-900">Reversements prestataires</h1>
<p className="mt-1 text-sm text-zinc-500">
Le marketplace encaisse centralisé sur System D ; voici les montants à reverser à chaque
prestataire pour les locations matériel des 6 derniers mois. System D n&apos;apparaît pas
dans la liste (commission 0 %).
</p>
</header>
<section className="grid grid-cols-2 gap-3 sm:grid-cols-3">
<KpiCard label="À payer" value={fmtEur(totalDue)} highlight />
<KpiCard label="Déjà payé" value={fmtEur(totalPaid)} />
<KpiCard label="Mois affichés" value={`${byMonth.size}`} />
</section>
{Array.from(byMonth.entries())
.sort((a, b) => b[0] - a[0])
.map(([periodTs, rows]) => {
const period = new Date(periodTs);
const monthDue = rows
.filter((r) => !r.paid && r.netAmount > 0)
.reduce((s, r) => s + r.netAmount, 0);
return (
<section
key={periodTs}
className="overflow-hidden rounded-lg border border-zinc-200 bg-white shadow-sm"
>
<header className="flex items-baseline justify-between border-b border-zinc-100 px-4 py-2">
<h2 className="text-sm font-semibold uppercase tracking-wider text-zinc-700">
{formatMonth(period)}
</h2>
<span className="text-xs text-zinc-500">
Reste à payer ce mois :{" "}
<span className="font-mono font-semibold text-zinc-900">{fmtEur(monthDue)}</span>
</span>
</header>
<table className="w-full text-sm">
<thead className="border-b border-zinc-100 bg-zinc-50 text-xs uppercase tracking-wider text-zinc-500">
<tr>
<th className="px-3 py-1.5 text-left font-semibold">Prestataire</th>
<th className="px-3 py-1.5 text-right font-semibold">Résa</th>
<th className="px-3 py-1.5 text-right font-semibold">CA brut</th>
<th className="px-3 py-1.5 text-right font-semibold">Commission</th>
<th className="px-3 py-1.5 text-right font-semibold">Net </th>
<th className="px-3 py-1.5 text-right font-semibold">Statut</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-100">
{rows
.sort((a, b) => b.netAmount - a.netAmount)
.map((p) => (
<tr key={`${p.providerId}-${periodTs}`} className="hover:bg-zinc-50">
<td className="px-3 py-1.5">
<Link
href={`/admin/rental-providers/${p.providerId}`}
className="text-zinc-900 hover:underline"
>
{p.providerName}
</Link>
</td>
<td className="px-3 py-1.5 text-right font-mono text-zinc-700">
{p.bookingsCount}
</td>
<td className="px-3 py-1.5 text-right font-mono text-zinc-700">
{fmtEur(p.grossAmount)}
</td>
<td className="px-3 py-1.5 text-right font-mono text-zinc-700">
{fmtEur(p.commission)}
</td>
<td className="px-3 py-1.5 text-right font-mono font-semibold text-zinc-900">
{fmtEur(p.netAmount)}
</td>
<td className="px-3 py-1.5">
<div className="flex justify-end">
<MarkPaidForm
payout={p}
markAction={markPayoutPaidAction}
unmarkAction={unmarkPayoutPaidAction}
/>
</div>
</td>
</tr>
))}
</tbody>
</table>
</section>
);
})}
</div>
);
}
function KpiCard({
label,
value,
highlight,
}: {
label: string;
value: string;
highlight?: boolean;
}) {
return (
<div
className={
"rounded-lg border bg-white px-4 py-3 shadow-sm " +
(highlight ? "border-emerald-300 bg-emerald-50/40" : "border-zinc-200")
}
>
<div className="text-[10px] uppercase tracking-wider text-zinc-500">{label}</div>
<div
className={
"mt-1 text-2xl font-semibold font-mono " +
(highlight ? "text-emerald-700" : "text-zinc-900")
}
>
{value}
</div>
</div>
);
}

View file

@ -2,6 +2,7 @@ import { notFound } from "next/navigation";
import Link from "next/link";
import { StatusBadge } from "@/components/admin/StatusBadge";
import { MediaUploader } from "@/components/MediaUploader";
import { getRentalItemForAdmin, listProvidersForSelect, RENTAL_CATEGORY_LABEL } from "@/lib/admin/rental-items";
import { ItemForm } from "../_components/ItemForm";
@ -56,6 +57,14 @@ export default async function EditRentalItemPage({ params }: PageProps) {
/>
</header>
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-3 text-base font-semibold text-zinc-900">Photos & vidéos</h2>
<MediaUploader
scope={{ kind: "rental-item", itemId: item.id }}
initialMedia={item.media}
/>
</section>
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<ItemForm
providers={providers}

View file

@ -26,7 +26,11 @@ export async function POST(
if (!session?.user?.id) {
return NextResponse.json({ error: "Non authentifié." }, { status: 401 });
}
if (session.user.role !== UserRole.OWNER && session.user.role !== UserRole.ADMIN) {
if (
session.user.role !== UserRole.OWNER &&
session.user.role !== UserRole.ADMIN &&
session.user.role !== UserRole.CE_MANAGER
) {
return NextResponse.json({ error: "Accès refusé." }, { status: 403 });
}
@ -34,12 +38,15 @@ export async function POST(
const carbet = await prisma.carbet.findUnique({
where: { id: carbetId },
select: { ownerId: true },
select: {
ownerId: true,
organizations: { select: { organizationId: true } },
},
});
if (!carbet) {
return NextResponse.json({ error: "Carbet introuvable." }, { status: 404 });
}
if (!canManageCarbet(session, carbet.ownerId)) {
if (!canManageCarbet(session, carbet.ownerId, carbet.organizations.map((o) => o.organizationId))) {
return NextResponse.json({ error: "Accès refusé." }, { status: 403 });
}

View file

@ -0,0 +1,113 @@
import { NextResponse } from "next/server";
import {
BookingStatus,
PaymentStatus,
RentalBookingStatus,
} from "@/generated/prisma/enums";
import { recordAudit } from "@/lib/admin/audit";
import { isAuthorizedCronRequest } from "@/lib/cron-auth";
import { prisma } from "@/lib/prisma";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const INVITE_EXPIRY_GRACE_DAYS = 30;
const ABANDONED_PENDING_DAYS = 7;
/**
* GET /api/cron/cleanup
*
* Purge :
* - OrgInviteToken expirés depuis plus de 30j (rétention pour audit court).
* - Booking carbet PENDING dont createdAt > 7j et paiement non SUCCEEDED :
* status passé à CANCELLED (libère le créneau via cascade des
* Availabilities seulement si onDelete CASCADE ici on flip juste
* status pour conserver le log).
* - RentalBooking PENDING idem + delete RentalItemAvailability associée
* (libère le stock).
*
* Auth : Bearer CRON_TOKEN.
*/
export async function GET(req: Request) {
if (!isAuthorizedCronRequest(req)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const now = new Date();
const inviteCutoff = new Date(now.getTime() - INVITE_EXPIRY_GRACE_DAYS * 86_400_000);
const abandonedCutoff = new Date(now.getTime() - ABANDONED_PENDING_DAYS * 86_400_000);
// 1. Invites expirés (expiresAt < cutoff)
const { count: invitesDeleted } = await prisma.orgInviteToken.deleteMany({
where: { expiresAt: { lt: inviteCutoff } },
});
// 2. Bookings carbet PENDING abandonnés
const abandonedBookings = await prisma.booking.findMany({
where: {
status: BookingStatus.PENDING,
paymentStatus: { not: PaymentStatus.SUCCEEDED },
createdAt: { lt: abandonedCutoff },
},
select: { id: true, carbetId: true },
});
let bookingsCancelled = 0;
if (abandonedBookings.length > 0) {
const { count } = await prisma.booking.updateMany({
where: { id: { in: abandonedBookings.map((b) => b.id) } },
data: { status: BookingStatus.CANCELLED, paymentStatus: PaymentStatus.FAILED },
});
bookingsCancelled = count;
}
// 3. RentalBookings PENDING abandonnés + delete availability associée
const abandonedRentals = await prisma.rentalBooking.findMany({
where: {
status: RentalBookingStatus.PENDING,
paymentStatus: { not: PaymentStatus.SUCCEEDED },
createdAt: { lt: abandonedCutoff },
},
select: { id: true },
});
let rentalsCancelled = 0;
let availabilityFreed = 0;
if (abandonedRentals.length > 0) {
const ids = abandonedRentals.map((r) => r.id);
const [rentalRes, availRes] = await prisma.$transaction([
prisma.rentalBooking.updateMany({
where: { id: { in: ids } },
data: {
status: RentalBookingStatus.CANCELLED,
paymentStatus: PaymentStatus.FAILED,
},
}),
prisma.rentalItemAvailability.deleteMany({
where: { rentalBookingId: { in: ids } },
}),
]);
rentalsCancelled = rentalRes.count;
availabilityFreed = availRes.count;
}
await recordAudit({
scope: "cron",
event: "cron.cleanup.run",
target: null,
actorEmail: "system:cron",
details: {
invitesDeleted,
bookingsCancelled,
rentalsCancelled,
availabilityFreed,
},
});
return NextResponse.json({
ok: true,
invitesDeleted,
bookingsCancelled,
rentalsCancelled,
availabilityFreed,
});
}

View file

@ -0,0 +1,128 @@
import { NextResponse } from "next/server";
import {
BookingStatus,
RentalBookingStatus,
} from "@/generated/prisma/enums";
import { recordAudit } from "@/lib/admin/audit";
import { isAuthorizedCronRequest } from "@/lib/cron-auth";
import { sendBookingReminder, sendRentalReminder } from "@/lib/email";
import { prisma } from "@/lib/prisma";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* GET /api/cron/reminders
*
* Envoie des rappels J-1 (24h avant le début) pour :
* - Booking CONFIRMED dont startDate [now+22h, now+26h]
* - RentalBooking CONFIRMED idem
*
* Idempotent à l'échelle d'une journée : le filtre temporel narrow limite
* naturellement le risque de double-envoi (en pratique le cron tourne 1× par
* jour à heure fixe). Pour une garantie at-most-once stricte il faudrait
* stocker un flag `reminderSentAt` sur Booking/RentalBooking défensif
* mais pas critique pour v1.
*
* Auth : Bearer CRON_TOKEN.
*/
export async function GET(req: Request) {
if (!isAuthorizedCronRequest(req)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const now = new Date();
const from = new Date(now.getTime() + 22 * 60 * 60 * 1000);
const to = new Date(now.getTime() + 26 * 60 * 60 * 1000);
const [carbetBookings, rentalBookings] = await Promise.all([
prisma.booking.findMany({
where: {
status: BookingStatus.CONFIRMED,
startDate: { gte: from, lt: to },
},
include: {
tenant: { select: { email: true, firstName: true } },
carbet: { select: { title: true, slug: true } },
},
}),
prisma.rentalBooking.findMany({
where: {
status: RentalBookingStatus.CONFIRMED,
startDate: { gte: from, lt: to },
},
include: {
tenant: { select: { email: true, firstName: true } },
provider: { select: { name: true, contactEmail: true, contactPhone: true } },
},
}),
]);
let bookingSent = 0;
let bookingErrors = 0;
for (const b of carbetBookings) {
if (!b.tenant.email) continue;
try {
await sendBookingReminder(
b.tenant.email,
b.tenant.firstName,
b.id,
b.carbet.title,
b.startDate,
b.carbet.slug,
);
bookingSent++;
} catch (e) {
bookingErrors++;
console.error(
"[cron.reminders] booking email failed:",
b.id,
e instanceof Error ? e.message : e,
);
}
}
let rentalSent = 0;
let rentalErrors = 0;
for (const r of rentalBookings) {
if (!r.tenant.email) continue;
try {
await sendRentalReminder(
r.tenant.email,
r.tenant.firstName,
r.id,
r.provider.name,
r.startDate,
{ email: r.provider.contactEmail, phone: r.provider.contactPhone },
);
rentalSent++;
} catch (e) {
rentalErrors++;
console.error(
"[cron.reminders] rental email failed:",
r.id,
e instanceof Error ? e.message : e,
);
}
}
await recordAudit({
scope: "cron",
event: "cron.reminders.run",
target: null,
actorEmail: "system:cron",
details: {
window: { from: from.toISOString(), to: to.toISOString() },
booking: { candidates: carbetBookings.length, sent: bookingSent, errors: bookingErrors },
rental: { candidates: rentalBookings.length, sent: rentalSent, errors: rentalErrors },
},
});
return NextResponse.json({
ok: true,
window: { from: from.toISOString(), to: to.toISOString() },
booking: { candidates: carbetBookings.length, sent: bookingSent, errors: bookingErrors },
rental: { candidates: rentalBookings.length, sent: rentalSent, errors: rentalErrors },
});
}

View file

@ -0,0 +1,39 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { recordAudit } from "@/lib/admin/audit";
import { prisma } from "@/lib/prisma";
import { canManageRentalProvider } from "@/lib/rental-access";
export const runtime = "nodejs";
export async function DELETE(_req: Request, ctx: { params: Promise<{ id: string }> }) {
const { id } = await ctx.params;
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
}
const media = await prisma.rentalItemMedia.findUnique({
where: { id },
select: { id: true, itemId: true, item: { select: { providerId: true } } },
});
if (!media) return NextResponse.json({ error: "Média introuvable" }, { status: 404 });
const allowed = await canManageRentalProvider(
session.user.id,
session.user.role,
media.item.providerId,
session.user.organizationId,
);
if (!allowed) return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
await prisma.rentalItemMedia.delete({ where: { id } });
await recordAudit({
scope: "uploads",
event: "rental.media.delete",
target: id,
actorEmail: session.user.email ?? null,
details: { itemId: media.itemId },
});
return NextResponse.json({ ok: true });
}

View file

@ -0,0 +1,75 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/auth";
import { recordAudit } from "@/lib/admin/audit";
import { prisma } from "@/lib/prisma";
import { canManageRentalProvider } from "@/lib/rental-access";
export const runtime = "nodejs";
const schema = z.object({
itemId: z.string().min(1),
orderedIds: z.array(z.string()).min(1).max(50),
});
export async function POST(req: Request) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
}
const parsed = schema.safeParse(await req.json().catch(() => ({})));
if (!parsed.success) {
return NextResponse.json({ error: "Payload invalide" }, { status: 400 });
}
const { itemId, orderedIds } = parsed.data;
const item = await prisma.rentalItem.findUnique({
where: { id: itemId },
select: { providerId: true },
});
if (!item) return NextResponse.json({ error: "Item introuvable" }, { status: 404 });
const allowed = await canManageRentalProvider(
session.user.id,
session.user.role,
item.providerId,
session.user.organizationId,
);
if (!allowed) return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
const existing = await prisma.rentalItemMedia.findMany({
where: { itemId, id: { in: orderedIds } },
select: { id: true },
});
if (existing.length !== orderedIds.length) {
return NextResponse.json({ error: "Certains médias n'appartiennent pas à l'item." }, { status: 400 });
}
await prisma.$transaction(
orderedIds.map((id, idx) =>
prisma.rentalItemMedia.update({ where: { id }, data: { sortOrder: idx } }),
),
);
// Cover = sortOrder 0 → hydrate imageUrl pour rétro-compat listings
const firstId = orderedIds[0];
const firstMedia = await prisma.rentalItemMedia.findUnique({
where: { id: firstId },
select: { s3Url: true, type: true },
});
if (firstMedia && firstMedia.type === "PHOTO") {
await prisma.rentalItem.update({
where: { id: itemId },
data: { imageUrl: firstMedia.s3Url },
});
}
await recordAudit({
scope: "uploads",
event: "rental.media.reorder",
target: itemId,
actorEmail: session.user.email ?? null,
details: { count: orderedIds.length },
});
return NextResponse.json({ ok: true });
}

View file

@ -0,0 +1,193 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import {
PaymentStatus,
RentalBookingStatus,
UserRole,
} from "@/generated/prisma/enums";
import { recordAudit } from "@/lib/admin/audit";
import { canManageRentalProvider } from "@/lib/rental-access";
import { sendRentalCancelled } from "@/lib/email";
import { isStripeConfigured, getStripeClient } from "@/lib/stripe";
import { prisma } from "@/lib/prisma";
import { computeRentalRefund } from "@/lib/rental-refund";
export const runtime = "nodejs";
const CANCELLABLE_STATUSES: RentalBookingStatus[] = [
RentalBookingStatus.PENDING,
RentalBookingStatus.CONFIRMED,
];
type Body = { reason?: string };
export async function POST(
req: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Non authentifié." }, { status: 401 });
}
const { id } = await params;
const body: Body = await req.json().catch(() => ({}));
const reason = body.reason?.toString().trim().slice(0, 500) ?? null;
const rb = await prisma.rentalBooking.findUnique({
where: { id },
include: {
provider: { select: { id: true, name: true, contactEmail: true, organizationId: true } },
tenant: { select: { id: true, email: true, firstName: true } },
lines: { select: { qty: true, item: { select: { name: true } } } },
},
});
if (!rb) {
return NextResponse.json({ error: "Réservation introuvable." }, { status: 404 });
}
// Détecte qui annule pour l'auth + l'email :
// - tenant de la booking
// - provider's manager (RENTAL_PROVIDER nominal ou CE_MANAGER de l'org du provider)
// - admin
const role = session.user.role;
const isTenant = rb.tenantId === session.user.id;
const isAdmin = role === UserRole.ADMIN;
const canManage = await canManageRentalProvider(
session.user.id,
role,
rb.providerId,
session.user.organizationId,
);
const cancelledBy: "tenant" | "provider" | "admin" = isAdmin
? "admin"
: canManage
? "provider"
: "tenant";
if (!isAdmin && !canManage && !isTenant) {
return NextResponse.json({ error: "Accès refusé." }, { status: 403 });
}
if (!CANCELLABLE_STATUSES.includes(rb.status)) {
return NextResponse.json(
{ error: `Impossible d'annuler une réservation en statut ${rb.status}.` },
{ status: 409 },
);
}
// Calcule le remboursement selon la politique
const refund = computeRentalRefund({
startDate: rb.startDate,
itemsTotal: rb.itemsTotal,
depositTotal: rb.depositTotal,
});
// Stripe refund best-effort si paiement déjà SUCCEEDED + session Stripe existante
let stripeRefundId: string | null = null;
let stripeRefundError: string | null = null;
if (
rb.paymentStatus === PaymentStatus.SUCCEEDED &&
rb.stripeSessionId &&
isStripeConfigured() &&
refund.totalRefund.gt(0)
) {
try {
const stripe = getStripeClient();
const sess = await stripe.checkout.sessions.retrieve(rb.stripeSessionId, {
expand: ["payment_intent"],
});
const piId =
typeof sess.payment_intent === "string"
? sess.payment_intent
: sess.payment_intent?.id;
if (piId) {
const stripeRefund = await stripe.refunds.create({
payment_intent: piId,
amount: Math.round(Number(refund.totalRefund) * 100),
reason: "requested_by_customer",
});
stripeRefundId = stripeRefund.id;
}
} catch (e) {
stripeRefundError = e instanceof Error ? e.message : String(e);
console.error("[rental.cancel] Stripe refund failed:", stripeRefundError);
}
}
// Transaction : update booking + delete availability blocks
await prisma.$transaction([
prisma.rentalBooking.update({
where: { id },
data: {
status: RentalBookingStatus.CANCELLED,
paymentStatus:
rb.paymentStatus === PaymentStatus.SUCCEEDED
? PaymentStatus.REFUNDED
: PaymentStatus.FAILED,
},
}),
prisma.rentalItemAvailability.deleteMany({
where: { rentalBookingId: id },
}),
]);
await recordAudit({
scope: "rental",
event: "rental.cancel",
target: id,
actorEmail: session.user.email ?? null,
details: {
cancelledBy,
reason,
policy: refund.policy,
itemsRefund: refund.itemsRefund.toString(),
depositRefund: refund.depositRefund.toString(),
totalRefund: refund.totalRefund.toString(),
stripeRefundId,
stripeRefundError,
},
});
// Email best-effort : tenant + provider
try {
await sendRentalCancelled(
rb.tenant.email,
rb.tenant.firstName,
rb.id,
rb.provider.name,
refund.totalRefund.toString(),
rb.currency,
refund.policyLabel,
cancelledBy,
);
if (rb.provider.contactEmail && cancelledBy !== "provider") {
await sendRentalCancelled(
rb.provider.contactEmail,
rb.provider.name,
rb.id,
rb.provider.name,
refund.totalRefund.toString(),
rb.currency,
refund.policyLabel,
cancelledBy,
);
}
} catch (e) {
console.error("[rental.cancel] email send failed:", e instanceof Error ? e.message : e);
}
return NextResponse.json({
ok: true,
rentalBookingId: id,
refund: {
itemsRefund: refund.itemsRefund.toNumber(),
depositRefund: refund.depositRefund.toNumber(),
totalRefund: refund.totalRefund.toNumber(),
policy: refund.policy,
policyLabel: refund.policyLabel,
},
stripeRefundId,
stripeRefundError,
});
}

View file

@ -0,0 +1,361 @@
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { PaymentStatus, RentalBookingStatus } from "@/generated/prisma/enums";
import { Prisma } from "@/generated/prisma/client";
import { recordAudit } from "@/lib/admin/audit";
import {
sendRentalRequestedProvider,
sendRentalRequestedTenant,
} from "@/lib/email";
import { isPluginEnabled } from "@/lib/plugins/server";
import { prisma } from "@/lib/prisma";
import { CART_COOKIE, EMPTY_CART, diffDays, parseCart } from "@/lib/rental-cart";
import {
getStripeClient,
isStripeConfigured,
toStripeAmountCents,
} from "@/lib/stripe";
export const runtime = "nodejs";
type LineInput = {
itemId: string;
qty: number;
startDate: Date;
endDate: Date;
nights: number;
};
function parseDateOnly(s: string): Date {
return new Date(s + "T00:00:00Z");
}
export async function POST() {
if (!(await isPluginEnabled("gear-rental"))) {
return NextResponse.json({ error: "Service de location indisponible." }, { status: 404 });
}
const session = await auth();
if (!session?.user?.id || !session.user.email) {
return NextResponse.json({ error: "Connectez-vous pour finaliser." }, { status: 401 });
}
const jar = await cookies();
const cart = parseCart(jar.get(CART_COOKIE)?.value);
if (cart.items.length === 0) {
return NextResponse.json({ error: "Panier vide." }, { status: 400 });
}
// Charge tous les items du panier
const itemIds = Array.from(new Set(cart.items.map((e) => e.itemId)));
const items = await prisma.rentalItem.findMany({
where: { id: { in: itemIds }, active: true },
include: {
provider: {
select: {
id: true,
name: true,
active: true,
approved: true,
commissionPct: true,
isSystemD: true,
},
},
},
});
const itemById = new Map(items.map((i) => [i.id, i]));
// Validations préliminaires : items valides + provider actif/approved
for (const entry of cart.items) {
const it = itemById.get(entry.itemId);
if (!it) {
return NextResponse.json(
{ error: `Item ${entry.itemId} introuvable ou désactivé.` },
{ status: 409 },
);
}
if (!it.provider.active || !it.provider.approved) {
return NextResponse.json(
{ error: `Prestataire ${it.provider.name} indisponible.` },
{ status: 409 },
);
}
if (entry.qty < 1 || entry.qty > it.totalQty) {
return NextResponse.json(
{ error: `Quantité invalide pour « ${it.name} ».` },
{ status: 400 },
);
}
const start = parseDateOnly(entry.startDate);
const end = parseDateOnly(entry.endDate);
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime()) || end <= start) {
return NextResponse.json(
{ error: `Dates invalides pour « ${it.name} ».` },
{ status: 400 },
);
}
}
// Groupe par provider
type Group = {
providerId: string;
providerName: string;
commissionPct: number;
lines: LineInput[];
itemsTotal: Prisma.Decimal;
depositTotal: Prisma.Decimal;
startDate: Date;
endDate: Date;
};
const groups = new Map<string, Group>();
for (const entry of cart.items) {
const it = itemById.get(entry.itemId)!;
const start = parseDateOnly(entry.startDate);
const end = parseDateOnly(entry.endDate);
const nights = Math.max(1, diffDays(entry.startDate, entry.endDate));
const lineSub = new Prisma.Decimal(it.pricePerDay).mul(entry.qty).mul(nights);
const lineDeposit = new Prisma.Decimal(it.deposit).mul(entry.qty);
let g = groups.get(it.provider.id);
if (!g) {
g = {
providerId: it.provider.id,
providerName: it.provider.name,
commissionPct: Number(it.provider.commissionPct),
lines: [],
itemsTotal: new Prisma.Decimal(0),
depositTotal: new Prisma.Decimal(0),
startDate: start,
endDate: end,
};
groups.set(it.provider.id, g);
}
g.lines.push({ itemId: it.id, qty: entry.qty, startDate: start, endDate: end, nights });
g.itemsTotal = g.itemsTotal.add(lineSub);
g.depositTotal = g.depositTotal.add(lineDeposit);
if (start < g.startDate) g.startDate = start;
if (end > g.endDate) g.endDate = end;
}
// Transaction : recheck stock + crée RentalBookings + Lines + Availabilities
let grandTotal = new Prisma.Decimal(0);
let grandDeposit = new Prisma.Decimal(0);
let rentalBookingIds: string[] = [];
try {
rentalBookingIds = await prisma.$transaction(async (tx) => {
const created: string[] = [];
for (const g of groups.values()) {
// Recheck stock disponible pour chaque ligne
for (const line of g.lines) {
const blocked = await tx.rentalItemAvailability.aggregate({
where: {
itemId: line.itemId,
startDate: { lt: line.endDate },
endDate: { gt: line.startDate },
},
_sum: { qty: true },
});
const item = itemById.get(line.itemId)!;
const used = Number(blocked._sum.qty ?? 0);
const free = item.totalQty - used;
if (line.qty > free) {
throw new Error(`Stock insuffisant pour « ${item.name} » sur les dates demandées (libre: ${free}).`);
}
}
const commissionAmount = g.itemsTotal
.mul(g.commissionPct)
.div(100)
.toDecimalPlaces(2);
const amount = g.itemsTotal.add(g.depositTotal).toDecimalPlaces(2);
const rb = await tx.rentalBooking.create({
data: {
tenantId: session.user!.id!,
providerId: g.providerId,
startDate: g.startDate,
endDate: g.endDate,
status: RentalBookingStatus.PENDING,
paymentStatus: PaymentStatus.PENDING,
itemsTotal: g.itemsTotal.toDecimalPlaces(2),
depositTotal: g.depositTotal.toDecimalPlaces(2),
commissionAmount,
amount,
currency: "EUR",
lines: {
create: g.lines.map((line) => {
const item = itemById.get(line.itemId)!;
const lineTotal = new Prisma.Decimal(item.pricePerDay)
.mul(line.qty)
.mul(line.nights)
.toDecimalPlaces(2);
return {
itemId: line.itemId,
qty: line.qty,
pricePerDay: new Prisma.Decimal(item.pricePerDay),
deposit: new Prisma.Decimal(item.deposit),
lineTotal,
};
}),
},
},
select: { id: true },
});
// Bloque les dispos
for (const line of g.lines) {
await tx.rentalItemAvailability.create({
data: {
itemId: line.itemId,
startDate: line.startDate,
endDate: line.endDate,
qty: line.qty,
reason: "RENTAL_BOOKING",
rentalBookingId: rb.id,
},
});
}
created.push(rb.id);
grandTotal = grandTotal.add(g.itemsTotal);
grandDeposit = grandDeposit.add(g.depositTotal);
}
return created;
});
} catch (e) {
return NextResponse.json(
{ error: e instanceof Error ? e.message : "Erreur lors de la création." },
{ status: 409 },
);
}
const totalAmount = grandTotal.add(grandDeposit).toDecimalPlaces(2);
await recordAudit({
scope: "rental",
event: "rental.checkout.created",
target: rentalBookingIds.join(","),
actorEmail: session.user.email,
details: {
rentalBookingIds,
amount: totalAmount.toNumber(),
depositTotal: grandDeposit.toNumber(),
providers: Array.from(groups.keys()),
},
});
// Emails best-effort : 1 mail au locataire (récap par prestataire) + 1 mail
// à chaque prestataire (sa demande). En cas d'échec d'envoi, on ne bloque pas.
try {
const fullBookings = await prisma.rentalBooking.findMany({
where: { id: { in: rentalBookingIds } },
include: {
provider: { select: { name: true, contactEmail: true } },
lines: { include: { item: { select: { name: true } } } },
},
});
const tenantName = session.user.name ?? session.user.email!;
for (const rb of fullBookings) {
const lineSummary = rb.lines.map((l) => ({ qty: l.qty, itemName: l.item.name }));
await sendRentalRequestedTenant(
session.user.email!,
tenantName,
rb.id,
rb.provider.name,
rb.startDate,
rb.endDate,
rb.amount.toString(),
rb.currency,
lineSummary,
);
if (rb.provider.contactEmail) {
await sendRentalRequestedProvider(
rb.provider.contactEmail,
rb.provider.name,
rb.id,
tenantName,
rb.startDate,
rb.endDate,
lineSummary,
);
}
}
} catch (e) {
console.error("[rental.checkout] email send failed:", e instanceof Error ? e.message : e);
}
// Vide le panier
jar.set(CART_COOKIE, JSON.stringify(EMPTY_CART), {
httpOnly: false,
sameSite: "lax",
path: "/",
maxAge: 0,
});
// Stripe ou paiement différé
if (!isStripeConfigured()) {
return NextResponse.json(
{ rentalBookingIds, totalAmount: totalAmount.toNumber() },
{ status: 201 },
);
}
const appUrl = process.env.APP_URL;
if (!appUrl) {
return NextResponse.json({ error: "APP_URL manquante." }, { status: 500 });
}
// Une session Stripe avec une ligne par RentalBooking (agrégée)
const stripe = getStripeClient();
const bookingDetails = await prisma.rentalBooking.findMany({
where: { id: { in: rentalBookingIds } },
include: {
provider: { select: { name: true } },
lines: { select: { qty: true, item: { select: { name: true } } } },
},
});
const line_items = bookingDetails.map((rb) => ({
quantity: 1,
price_data: {
currency: "eur",
unit_amount: toStripeAmountCents(Number(rb.amount)),
product_data: {
name: `Matériel — ${rb.provider.name}`,
description: rb.lines.map((l) => `${l.qty}× ${l.item.name}`).join(", ").slice(0, 500),
},
},
}));
const checkoutSession = await stripe.checkout.sessions.create({
mode: "payment",
success_url: `${appUrl}/mes-locations?payment=success&ids=${rentalBookingIds.join(",")}`,
cancel_url: `${appUrl}/panier?payment=cancel`,
customer_email: session.user.email,
line_items,
metadata: {
type: "rental-bundle",
rentalBookingIds: rentalBookingIds.join(","),
},
});
await prisma.rentalBooking.updateMany({
where: { id: { in: rentalBookingIds } },
data: { stripeSessionId: checkoutSession.id },
});
return NextResponse.json(
{
rentalBookingIds,
totalAmount: totalAmount.toNumber(),
checkoutSessionId: checkoutSession.id,
checkoutUrl: checkoutSession.url,
},
{ status: 201 },
);
}

View file

@ -0,0 +1,31 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { getItemAvailability } from "@/lib/rentals-public";
import { parseIsoDate, normalizeUtcDayStart } from "@/lib/booking";
export const runtime = "nodejs";
export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
const { id } = await ctx.params;
const from = parseIsoDate(req.nextUrl.searchParams.get("from"));
const to = parseIsoDate(req.nextUrl.searchParams.get("to"));
if (!from || !to) {
return NextResponse.json(
{ error: "Paramètres from et to (YYYY-MM-DD) requis." },
{ status: 400 },
);
}
const start = normalizeUtcDayStart(from);
const end = normalizeUtcDayStart(to);
if (end <= start) {
return NextResponse.json({ error: "to doit être > from." }, { status: 400 });
}
const calendar = await getItemAvailability(id, start, end);
return NextResponse.json({
itemId: id,
from: start.toISOString(),
to: end.toISOString(),
calendar,
});
}

View file

@ -2,11 +2,13 @@ import { NextResponse } from "next/server";
import { z } from "zod";
import { UserRole } from "@/generated/prisma/enums";
import { getOrgInviteByToken, markOrgInviteConsumed } from "@/lib/ce-invites";
import { hashPassword } from "@/lib/password";
import { prisma } from "@/lib/prisma";
import { recordAudit } from "@/lib/admin/audit";
import { sendSignupWelcome } from "@/lib/email";
import { sendNewCeRequest, sendNewRentalProviderRequest, sendSignupWelcome } from "@/lib/email";
import { rateLimitRequest } from "@/lib/rate-limit";
import { slugify } from "@/lib/slug";
export const runtime = "nodejs";
@ -16,11 +18,16 @@ const schema = z.object({
firstName: z.string().trim().min(1).max(100),
lastName: z.string().trim().min(1).max(100),
phone: z.string().trim().max(40).optional().nullable(),
role: z.enum([UserRole.TOURIST, UserRole.OWNER]).default(UserRole.TOURIST),
role: z
.enum([UserRole.TOURIST, UserRole.OWNER, UserRole.RENTAL_PROVIDER, UserRole.CE_MANAGER])
.default(UserRole.TOURIST),
providerName: z.string().trim().min(2).max(200).optional(),
providerRivers: z.array(z.string().trim().min(1).max(80)).max(20).optional(),
orgName: z.string().trim().min(2).max(200).optional(),
inviteToken: z.string().trim().min(8).max(200).optional(),
});
export async function POST(req: Request) {
// 5 inscriptions max par IP par heure.
const rl = rateLimitRequest(req, "signup", 60 * 60 * 1000, 5);
if (!rl.ok) {
return NextResponse.json(
@ -43,35 +50,150 @@ export async function POST(req: Request) {
}
const data = parsed.data;
if (data.role === UserRole.RENTAL_PROVIDER && (!data.providerName || data.providerName.trim().length < 2)) {
return NextResponse.json({ error: "Nom de votre activité requis." }, { status: 400 });
}
if (data.role === UserRole.CE_MANAGER && (!data.orgName || data.orgName.trim().length < 2)) {
return NextResponse.json({ error: "Nom de votre Comité d'Entreprise requis." }, { status: 400 });
}
// Invitation CE_MEMBER : si un inviteToken est fourni, on force le rôle CE_MEMBER
// et on rattache à l'org du token (org déjà validée — pas de bannière pending).
let inviteOrgId: string | null = null;
if (data.inviteToken) {
const invite = await getOrgInviteByToken(data.inviteToken);
if (!invite) {
return NextResponse.json({ error: "Lien d'invitation invalide ou expiré." }, { status: 400 });
}
if (invite.email && invite.email.toLowerCase() !== data.email) {
return NextResponse.json(
{ error: "Ce lien d'invitation est réservé à un autre email." },
{ status: 400 },
);
}
inviteOrgId = invite.organizationId;
}
const existing = await prisma.user.findUnique({ where: { email: data.email }, select: { id: true } });
if (existing) {
return NextResponse.json({ error: "Un compte existe déjà avec cet email." }, { status: 409 });
}
const passwordHash = await hashPassword(data.password);
const user = await prisma.user.create({
data: {
email: data.email,
passwordHash,
firstName: data.firstName,
lastName: data.lastName,
phone: data.phone?.trim() || null,
role: data.role,
isActive: true,
},
select: { id: true, email: true, role: true },
});
// CE_MANAGER : transaction atomique User + Organization. Le slug est unique
// sur Organization → on retente avec un suffixe en cas de collision.
let createdProviderId: string | null = null;
let createdOrgId: string | null = null;
let user: { id: string; email: string; role: UserRole };
if (inviteOrgId) {
// Branche invite CE_MEMBER : rattache le user à l'org du token, ignore data.role.
user = await prisma.user.create({
data: {
email: data.email,
passwordHash,
firstName: data.firstName,
lastName: data.lastName,
phone: data.phone?.trim() || null,
role: UserRole.CE_MEMBER,
organizationId: inviteOrgId,
isActive: true,
},
select: { id: true, email: true, role: true },
});
createdOrgId = inviteOrgId;
await markOrgInviteConsumed(data.inviteToken!).catch(() => {});
} else if (data.role === UserRole.CE_MANAGER) {
const orgName = data.orgName!.trim();
const baseSlug = slugify(orgName);
const result = await prisma.$transaction(async (tx) => {
// Trouve un slug libre
let candidate = baseSlug || "ce";
let suffix = 1;
for (;;) {
const exists = await tx.organization.findUnique({ where: { slug: candidate }, select: { id: true } });
if (!exists) break;
suffix += 1;
candidate = `${baseSlug}-${suffix}`;
}
// candidate now holds a free slug
const org = await tx.organization.create({
data: {
name: orgName,
slug: candidate,
contactEmail: data.email,
approved: false,
},
select: { id: true },
});
const u = await tx.user.create({
data: {
email: data.email,
passwordHash,
firstName: data.firstName,
lastName: data.lastName,
phone: data.phone?.trim() || null,
role: UserRole.CE_MANAGER,
organizationId: org.id,
isActive: true,
},
select: { id: true, email: true, role: true },
});
return { user: u, orgId: org.id };
});
user = result.user;
createdOrgId = result.orgId;
sendNewCeRequest(orgName, user.email).catch(() => {});
} else {
user = await prisma.user.create({
data: {
email: data.email,
passwordHash,
firstName: data.firstName,
lastName: data.lastName,
phone: data.phone?.trim() || null,
role: data.role,
isActive: true,
},
select: { id: true, email: true, role: true },
});
// Pour un RENTAL_PROVIDER : crée le RentalProvider associé en attente d'approbation.
if (user.role === UserRole.RENTAL_PROVIDER && data.providerName) {
const provider = await prisma.rentalProvider.create({
data: {
name: data.providerName,
isSystemD: false,
managedByUserId: user.id,
contactEmail: user.email,
contactPhone: data.phone?.trim() || null,
rivers: data.providerRivers ?? [],
commissionPct: 10, // valeur par défaut, ajustable par admin
active: true,
approved: false,
},
select: { id: true, name: true },
});
createdProviderId = provider.id;
sendNewRentalProviderRequest(provider.name, user.email).catch(() => {});
}
}
await recordAudit({
scope: "public.signup",
event: "user.create",
target: user.id,
actorEmail: user.email,
details: { role: user.role },
details: { role: user.role, rentalProviderId: createdProviderId, organizationId: createdOrgId },
});
// Best-effort welcome email.
sendSignupWelcome(user.email, data.firstName).catch(() => {});
return NextResponse.json({ ok: true, userId: user.id });
return NextResponse.json({
ok: true,
userId: user.id,
providerId: createdProviderId,
organizationId: createdOrgId,
});
}

View file

@ -4,9 +4,11 @@ import Stripe from "stripe";
import {
BookingStatus,
PaymentStatus,
RentalBookingStatus,
SubscriptionStatus,
} from "@/generated/prisma/enums";
import { refreshCarbetLastBookedAt } from "@/lib/carbet-last-booked";
import { sendRentalConfirmed } from "@/lib/email";
import { prisma } from "@/lib/prisma";
import { fromStripeTimestamp, getStripeClient } from "@/lib/stripe";
@ -51,6 +53,43 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
return;
}
if (type === "rental-bundle") {
const idsRaw = session.metadata?.rentalBookingIds;
if (!idsRaw) return;
const ids = idsRaw.split(",").map((s) => s.trim()).filter(Boolean);
if (ids.length === 0) return;
await prisma.rentalBooking.updateMany({
where: { id: { in: ids } },
data: {
paymentStatus: PaymentStatus.SUCCEEDED,
status: RentalBookingStatus.CONFIRMED,
},
});
try {
const rentals = await prisma.rentalBooking.findMany({
where: { id: { in: ids } },
include: {
provider: { select: { name: true } },
tenant: { select: { email: true, firstName: true } },
},
});
for (const rb of rentals) {
if (!rb.tenant.email) continue;
await sendRentalConfirmed(
rb.tenant.email,
rb.tenant.firstName ?? rb.tenant.email,
rb.id,
rb.provider.name,
rb.startDate,
rb.endDate,
);
}
} catch (e) {
console.error("[webhook.rental] email send failed:", e instanceof Error ? e.message : e);
}
return;
}
if (type === "owner_subscription") {
const ownerId = session.metadata?.ownerId;
const carbetId = session.metadata?.carbetId;
@ -79,6 +118,27 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
async function handlePaymentIntentFailed(paymentIntent: Stripe.PaymentIntent) {
const bookingId = paymentIntent.metadata?.bookingId;
const rentalIdsRaw = paymentIntent.metadata?.rentalBookingIds;
if (rentalIdsRaw) {
const ids = rentalIdsRaw.split(",").map((s) => s.trim()).filter(Boolean);
if (ids.length > 0) {
// Marque les paiements échoués + libère les blocages de dispo
await prisma.$transaction([
prisma.rentalBooking.updateMany({
where: { id: { in: ids } },
data: {
paymentStatus: PaymentStatus.FAILED,
status: RentalBookingStatus.CANCELLED,
},
}),
prisma.rentalItemAvailability.deleteMany({
where: { rentalBookingId: { in: ids } },
}),
]);
}
}
if (!bookingId) {
return;
}

View file

@ -0,0 +1,105 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/auth";
import { MediaType } from "@/generated/prisma/enums";
import { recordAudit } from "@/lib/admin/audit";
import { prisma } from "@/lib/prisma";
import { canManageRentalProvider } from "@/lib/rental-access";
import { classifyMime } from "@/lib/uploads";
import { generateImageVariants } from "@/lib/variants-server";
export const runtime = "nodejs";
const schema = z.object({
itemId: z.string().min(1),
s3Key: z.string().min(5).max(500),
s3Url: z.string().url(),
mime: z.string().min(3).max(100),
});
export async function POST(req: Request) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
}
const parsed = schema.safeParse(await req.json().catch(() => ({})));
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.issues[0]?.message ?? "Payload invalide" },
{ status: 400 },
);
}
const kind = classifyMime(parsed.data.mime);
if (!kind) return NextResponse.json({ error: "Type non supporté" }, { status: 400 });
const item = await prisma.rentalItem.findUnique({
where: { id: parsed.data.itemId },
select: { id: true, providerId: true },
});
if (!item) return NextResponse.json({ error: "Item introuvable" }, { status: 404 });
const allowed = await canManageRentalProvider(
session.user.id,
session.user.role,
item.providerId,
session.user.organizationId,
);
if (!allowed) {
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
}
if (!parsed.data.s3Key.startsWith(`rental-items/${item.id}/`)) {
return NextResponse.json({ error: "s3Key invalide pour cet item" }, { status: 400 });
}
const existingCount = await prisma.rentalItemMedia.count({ where: { itemId: item.id } });
const media = await prisma.rentalItemMedia.create({
data: {
itemId: item.id,
type: kind === "photo" ? MediaType.PHOTO : MediaType.VIDEO,
s3Key: parsed.data.s3Key,
s3Url: parsed.data.s3Url,
sortOrder: existingCount,
},
select: { id: true, type: true, s3Url: true, s3Key: true, sortOrder: true },
});
// Si c'est la première photo de l'item, hydrate imageUrl pour rétro-compat
// avec les listings (RentalItemCard, /carbets/[slug] panel).
if (existingCount === 0 && kind === "photo") {
await prisma.rentalItem.update({
where: { id: item.id },
data: { imageUrl: parsed.data.s3Url },
});
}
await recordAudit({
scope: "uploads",
event: "rental.media.finalize",
target: media.id,
actorEmail: session.user.email ?? null,
details: { itemId: item.id, kind },
});
try {
const variants = await generateImageVariants({
originalS3Key: parsed.data.s3Key,
mime: parsed.data.mime,
});
if (!variants.skipped) {
const okCount = variants.results.filter((r) => r.ok).length;
await recordAudit({
scope: "uploads",
event: "rental.media.variants",
target: media.id,
actorEmail: session.user.email ?? null,
details: { generated: okCount, total: variants.results.length },
});
}
} catch (e) {
console.error("[rental-uploads] variants generation error:", e);
}
return NextResponse.json({ media });
}

View file

@ -0,0 +1,63 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
import { canManageRentalProvider } from "@/lib/rental-access";
import { rateLimitRequest } from "@/lib/rate-limit";
import { presignRentalItemUpload } from "@/lib/uploads";
export const runtime = "nodejs";
const schema = z.object({
itemId: z.string().min(1),
mime: z.string().min(3).max(100),
sizeBytes: z.coerce.number().int().min(1).max(500 * 1024 * 1024),
});
export async function POST(req: Request) {
const rl = rateLimitRequest(req, "rental-presign", 60_000, 60);
if (!rl.ok) {
return NextResponse.json(
{ error: `Trop de demandes. Réessayez dans ${rl.retryAfter}s.` },
{ status: 429 },
);
}
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
}
const parsed = schema.safeParse(await req.json().catch(() => ({})));
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.issues[0]?.message ?? "Payload invalide" },
{ status: 400 },
);
}
const item = await prisma.rentalItem.findUnique({
where: { id: parsed.data.itemId },
select: { id: true, providerId: true },
});
if (!item) return NextResponse.json({ error: "Item introuvable" }, { status: 404 });
const allowed = await canManageRentalProvider(
session.user.id,
session.user.role,
item.providerId,
session.user.organizationId,
);
if (!allowed) {
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
}
const result = await presignRentalItemUpload({
itemId: item.id,
mime: parsed.data.mime,
sizeBytes: parsed.data.sizeBytes,
});
if ("error" in result) {
return NextResponse.json({ error: result.error }, { status: 400 });
}
return NextResponse.json(result);
}

View file

@ -0,0 +1,105 @@
import Link from "next/link";
import { isPluginEnabled } from "@/lib/plugins/server";
import { prisma } from "@/lib/prisma";
import { RENTAL_CATEGORY_LABEL } from "@/lib/rental-category-labels";
type Props = {
river: string;
capacity: number;
};
const EMOJI: Record<string, string> = {
SLEEP: "💤",
NAVIGATION: "🛶",
FISHING: "🎣",
COOKING: "🍳",
SAFETY: "🦺",
};
export async function CompleteYourStay({ river, capacity }: Props) {
if (!(await isPluginEnabled("gear-rental"))) return null;
const providers = await prisma.rentalProvider.findMany({
where: {
active: true,
approved: true,
OR: [
{ isSystemD: true },
{ rivers: { has: river } },
],
},
select: {
id: true,
items: {
where: { active: true },
orderBy: [{ category: "asc" }, { pricePerDay: "asc" }],
take: 24,
select: {
id: true,
name: true,
category: true,
imageUrl: true,
pricePerDay: true,
provider: { select: { name: true, isSystemD: true } },
},
},
},
});
const items = providers.flatMap((p) => p.items).slice(0, 9);
if (items.length === 0) return null;
return (
<section className="my-8 rounded-lg border border-emerald-200 bg-emerald-50/40 p-5">
<header className="flex items-baseline justify-between gap-3">
<div>
<h2 className="text-lg font-semibold text-emerald-900">
Compléter votre séjour
</h2>
<p className="text-xs text-emerald-800">
Pour {capacity} voyageur{capacity > 1 ? "s" : ""} sur le {river},
pensez à louer hamacs, moustiquaires, pirogue ou kayak auprès des
prestataires locaux.
</p>
</div>
<Link href="/materiel" className="text-xs font-semibold text-emerald-800 hover:underline">
Voir tout
</Link>
</header>
<ul className="mt-3 grid grid-cols-2 gap-2 sm:grid-cols-3">
{items.map((it) => (
<li
key={it.id}
className="overflow-hidden rounded-md border border-emerald-100 bg-white shadow-sm"
>
<Link href={`/materiel/${it.id}`} className="block">
<div className="flex aspect-video items-center justify-center bg-emerald-50 text-3xl">
{it.imageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={it.imageUrl} alt={it.name} className="h-full w-full object-cover" />
) : (
<span>{EMOJI[it.category] ?? "🎒"}</span>
)}
</div>
<div className="px-2.5 py-1.5">
<p className="truncate text-xs font-semibold text-zinc-900">{it.name}</p>
<div className="flex items-center justify-between text-[10px] text-zinc-500">
<span>{RENTAL_CATEGORY_LABEL[it.category]}</span>
<span className="font-mono font-semibold text-emerald-700">
{Number(it.pricePerDay).toFixed(0)} /j
</span>
</div>
{it.provider.isSystemD ? (
<span className="mt-1 inline-block rounded-full bg-emerald-600 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-white">
Karbé
</span>
) : null}
</div>
</Link>
</li>
))}
</ul>
</section>
);
}

View file

@ -15,6 +15,7 @@ import { formatAverageRating } from "@/lib/reviews";
import { isStripeConfigured } from "@/lib/stripe";
import { BookingForm } from "../_components/booking-form";
import { CompleteYourStay } from "./_components/CompleteYourStay";
import { CarbetGallery } from "../_components/carbet-gallery";
import { CarbetMap } from "../_components/carbet-map";
import { ReviewsSection } from "../_components/reviews-section";
@ -114,6 +115,17 @@ export default async function PublicCarbetPage({ params }: PageProps) {
? ` · Route + ${formatPirogueDuration(carbet.pirogueDurationMin)} pirogue depuis ${carbet.embarkPoint}`
: ` · Route directe (embarquement ${carbet.embarkPoint})`}
</p>
{carbet.organizations.length > 0 ? (
<p className="mt-1 text-xs text-zinc-500">
Géré par le CE{" "}
{carbet.organizations.map((o, i) => (
<span key={o.id}>
<strong className="text-zinc-700">{o.name}</strong>
{i < carbet.organizations.length - 1 ? ", " : ""}
</span>
))}
</p>
) : null}
{carbet.reviewStats.count > 0 &&
carbet.reviewStats.averageRating !== null ? (
<p className="mt-2 flex items-center gap-2 text-sm text-zinc-700">
@ -277,6 +289,8 @@ export default async function PublicCarbetPage({ params }: PageProps) {
</aside>
</div>
<CompleteYourStay river={carbet.river} capacity={carbet.capacity} />
<ReviewsSection
stats={carbet.reviewStats}
reviews={carbet.reviews}

View file

@ -182,7 +182,8 @@ export function BookingForm({
max={capacity}
value={guestCount}
onChange={(e) => setGuestCount(Math.max(1, Math.min(capacity, Number(e.target.value) || 1)))}
className="mt-0.5 w-full rounded-md border border-zinc-300 px-2 py-1.5"
inputMode="numeric"
className="mt-0.5 block w-full min-h-[44px] rounded-md border border-zinc-300 px-3 py-2 text-base"
/>
</label>
@ -215,7 +216,7 @@ export function BookingForm({
type="button"
onClick={submit}
disabled={!canSubmit}
className="w-full rounded-md bg-emerald-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
className="w-full min-h-[44px] rounded-md bg-emerald-600 px-4 py-3 text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
>
{busy
? "Envoi…"

View file

@ -0,0 +1,95 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { MonthlyRevenueChart } from "@/components/analytics/MonthlyRevenueChart";
import { getCarbetsOccupancy, getMonthlyRevenueSeries } from "@/lib/analytics";
import { getCurrentCeOrganization } from "@/lib/ce-access";
export const dynamic = "force-dynamic";
export const metadata = { title: "Analytics CE — Karbé" };
function fmtEur(n: number): string {
return n.toLocaleString("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 });
}
export default async function CeAnalyticsPage() {
const org = await getCurrentCeOrganization();
if (!org) redirect("/admin/organizations");
const [series, occupancy] = await Promise.all([
getMonthlyRevenueSeries({ organizationId: org.id, monthsBack: 12 }),
getCarbetsOccupancy({ organizationId: org.id, monthsBack: 3 }),
]);
const total12m = series.reduce((s, p) => s + p.total, 0);
const totalCarbet12m = series.reduce((s, p) => s + p.carbetRevenue, 0);
const totalRental12m = series.reduce((s, p) => s + p.rentalRevenue, 0);
return (
<main className="mx-auto max-w-5xl px-6 py-10 space-y-6">
<header>
<Link href="/espace-ce" className="text-xs text-zinc-500 hover:text-zinc-900">
Tableau de bord CE
</Link>
<h1 className="mt-1 text-3xl font-semibold text-zinc-900">
Analytics {org.name}
</h1>
<p className="mt-1 text-sm text-zinc-600">
Chiffre d&apos;affaires des 12 derniers mois et taux d&apos;occupation des carbets co-gérés.
</p>
</header>
<section className="grid grid-cols-2 gap-3 sm:grid-cols-3">
<KpiCard label="CA 12 mois" value={fmtEur(total12m)} />
<KpiCard label="dont Carbet" value={fmtEur(totalCarbet12m)} />
<KpiCard label="dont Matériel" value={fmtEur(totalRental12m)} />
</section>
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">
Chiffre d&apos;affaires mensuel
</h2>
<MonthlyRevenueChart data={series} />
</section>
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">
Taux d&apos;occupation des carbets (3 derniers mois)
</h2>
{occupancy.length === 0 ? (
<p className="text-sm text-zinc-500">Pas encore de carbet publié.</p>
) : (
<ul className="space-y-2">
{occupancy.map((c) => (
<li key={c.carbetId}>
<div className="flex items-baseline justify-between text-sm">
<Link href={`/carbets/${c.slug}`} className="font-medium text-zinc-900 hover:underline">
{c.title}
</Link>
<span className="font-mono text-zinc-700">
{c.occupancyPct} % ({c.bookedNights}/{c.totalNights} nuits)
</span>
</div>
<div className="mt-1 h-2 overflow-hidden rounded-full bg-zinc-100">
<div
className="h-full bg-emerald-500"
style={{ width: `${Math.min(100, c.occupancyPct)}%` }}
/>
</div>
</li>
))}
</ul>
)}
</section>
</main>
);
}
function KpiCard({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-lg border border-zinc-200 bg-white px-4 py-3 shadow-sm">
<div className="text-[10px] uppercase tracking-wider text-zinc-500">{label}</div>
<div className="mt-1 text-xl font-semibold text-zinc-900 font-mono">{value}</div>
</div>
);
}

View file

@ -0,0 +1,126 @@
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { MediaUploader } from "@/components/MediaUploader";
import { canManageCarbet, requireOwnerSession } from "@/lib/carbet-access";
import { getCurrentCeOrganization } from "@/lib/ce-access";
import { prisma } from "@/lib/prisma";
import { updateCarbet } from "../../../espace-hote/carbets/actions";
import { CarbetForm } from "../../../espace-hote/carbets/_components/carbet-form";
export const dynamic = "force-dynamic";
export default async function EditCeCarbetPage({
params,
searchParams,
}: {
params: Promise<{ carbetId: string }>;
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}) {
const session = await requireOwnerSession();
const org = await getCurrentCeOrganization();
if (!org) redirect("/admin/organizations");
const { carbetId } = await params;
const { publishError } = await searchParams;
const carbet = await prisma.carbet.findUnique({
where: { id: carbetId },
select: {
id: true,
ownerId: true,
title: true,
description: true,
river: true,
latitude: true,
longitude: true,
embarkPoint: true,
pirogueDurationMin: true,
capacity: true,
roadAccess: true,
electricity: true,
gsmAtCarbet: true,
gsmExitDistanceKm: true,
status: true,
media: {
orderBy: { sortOrder: "asc" },
select: { id: true, type: true, s3Url: true, s3Key: true, sortOrder: true },
},
amenities: { select: { amenity: { select: { key: true } } } },
organizations: { select: { organizationId: true } },
},
});
if (
!carbet ||
!canManageCarbet(session, carbet.ownerId, carbet.organizations.map((o) => o.organizationId))
) {
notFound();
}
// Sécurité supplémentaire : assure que le carbet est bien lié à l'org du user.
// (Un ADMIN peut éditer n'importe quel carbet via /admin, pas via /espace-ce.)
const isLinked = carbet.organizations.some((o) => o.organizationId === org.id);
if (!isLinked && session.user.role !== "ADMIN") {
notFound();
}
const defaults = {
title: carbet.title,
description: carbet.description,
river: carbet.river,
latitude: carbet.latitude.toString(),
longitude: carbet.longitude.toString(),
embarkPoint: carbet.embarkPoint,
pirogueDurationMin: String(carbet.pirogueDurationMin),
capacity: String(carbet.capacity),
roadAccess: carbet.roadAccess ?? "",
electricity: carbet.electricity ?? "",
gsmAtCarbet: carbet.gsmAtCarbet,
gsmExitDistanceKm: carbet.gsmExitDistanceKm !== null ? carbet.gsmExitDistanceKm.toString() : "",
status: carbet.status,
amenityKeys: carbet.amenities.map((entry) => entry.amenity.key),
};
return (
<main className="mx-auto max-w-3xl px-6 py-12">
<Link
href="/espace-ce/carbets"
className="text-sm text-zinc-600 hover:text-zinc-900"
>
Carbets de {org.name}
</Link>
<h1 className="mt-2 text-3xl font-semibold text-zinc-900">
{carbet.title}
</h1>
<p className="mt-1 text-xs text-zinc-500">
Co-géré par <strong>{org.name}</strong>
</p>
{publishError ? (
<p className="mt-4 rounded-md bg-amber-50 px-4 py-3 text-sm text-amber-800">
Ajoutez au moins un média avant de publier ce carbet.
</p>
) : null}
<section className="mt-8">
<h2 className="text-lg font-semibold text-zinc-900">Médias</h2>
<p className="mb-4 mt-1 text-sm text-zinc-600">
Déposez photos et vidéos courtes, réorganisez par glisser-déposer.
Le premier média sert de cover sur le catalogue.
</p>
<MediaUploader carbetId={carbet.id} initialMedia={carbet.media} />
</section>
<section className="mt-10 border-t border-zinc-200 pt-8">
<CarbetForm
action={updateCarbet}
mode="edit"
carbetId={carbet.id}
defaults={defaults}
submitLabel="Enregistrer les modifications"
/>
</section>
</main>
);
}

View file

@ -0,0 +1,42 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { requireApprovedOrg } from "@/lib/ce-access";
import { createCarbet } from "../../../espace-hote/carbets/actions";
import { CarbetForm } from "../../../espace-hote/carbets/_components/carbet-form";
export const dynamic = "force-dynamic";
export default async function NewCeCarbetPage() {
// Bloque la création si l'org n'est pas validée — redirect vers dashboard
// avec bannière « En attente de validation ».
const org = await requireApprovedOrg();
if (!org) redirect("/espace-ce");
return (
<main className="mx-auto max-w-3xl px-6 py-12">
<Link
href="/espace-ce/carbets"
className="text-sm text-zinc-600 hover:text-zinc-900"
>
Carbets de {org.name}
</Link>
<h1 className="mt-2 text-3xl font-semibold text-zinc-900">
Nouveau carbet CE
</h1>
<p className="mt-1 text-sm text-zinc-600">
Ce carbet sera automatiquement lié à <strong>{org.name}</strong> et co-géré
par tous ses CE_MANAGERs. Vous ajouterez les médias après la création.
</p>
<div className="mt-8">
<CarbetForm
action={createCarbet}
mode="create"
submitLabel="Créer le carbet"
/>
</div>
</main>
);
}

View file

@ -0,0 +1,163 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { CarbetStatus } from "@/generated/prisma/enums";
import { getCurrentCeOrganization } from "@/lib/ce-access";
import { prisma } from "@/lib/prisma";
import {
deleteCarbet,
setCarbetStatus,
} from "../../espace-hote/carbets/actions";
export const dynamic = "force-dynamic";
export const metadata = { title: "Mes carbets CE — Karbé" };
const STATUS_LABELS: Record<CarbetStatus, string> = {
[CarbetStatus.DRAFT]: "Brouillon",
[CarbetStatus.PUBLISHED]: "Publié",
[CarbetStatus.ARCHIVED]: "Archivé",
};
const STATUS_STYLES: Record<CarbetStatus, string> = {
[CarbetStatus.DRAFT]: "bg-zinc-100 text-zinc-700",
[CarbetStatus.PUBLISHED]: "bg-emerald-100 text-emerald-800",
[CarbetStatus.ARCHIVED]: "bg-amber-100 text-amber-800",
};
export default async function CeCarbetsListPage() {
const org = await getCurrentCeOrganization();
if (!org) redirect("/admin/organizations");
const memberships = await prisma.organizationCarbetMembership.findMany({
where: { organizationId: org.id },
orderBy: { addedAt: "desc" },
select: {
carbet: {
select: {
id: true,
title: true,
river: true,
status: true,
updatedAt: true,
ownerId: true,
owner: { select: { firstName: true, lastName: true } },
_count: { select: { media: true } },
},
},
},
});
const carbets = memberships.map((m) => m.carbet);
return (
<main className="mx-auto max-w-4xl px-6 py-12">
<div className="flex items-center justify-between gap-3">
<div>
<Link href="/espace-ce" className="text-xs text-zinc-500 hover:text-zinc-900">
Tableau de bord CE
</Link>
<h1 className="mt-1 text-3xl font-semibold text-zinc-900">
Carbets co-gérés par {org.name}
</h1>
<p className="mt-1 text-sm text-zinc-600">
Les carbets visibles ici peuvent être édités par tous les CE_MANAGERs de votre
organisation. La propriété nominale reste sur leur créateur initial.
</p>
</div>
{org.approved ? (
<Link
href="/espace-ce/carbets/nouveau"
className="rounded-md bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700"
>
Nouveau carbet
</Link>
) : (
<span className="rounded-md bg-zinc-100 px-3 py-2 text-xs text-zinc-500">
Publication bloquée : organisation en attente de validation
</span>
)}
</div>
{carbets.length === 0 ? (
<p className="mt-10 rounded-md border border-dashed border-zinc-300 px-6 py-12 text-center text-sm text-zinc-500">
Votre CE n&apos;a pas encore de carbet.{" "}
{org.approved ? "Créez votre premier carbet pour démarrer." : "Vous pourrez en publier dès que votre organisation sera validée."}
</p>
) : (
<ul className="mt-8 space-y-4">
{carbets.map((carbet) => (
<li
key={carbet.id}
className="flex flex-col gap-4 rounded-lg border border-zinc-200 bg-white p-5 sm:flex-row sm:items-center sm:justify-between"
>
<div className="min-w-0">
<div className="flex items-center gap-3">
<Link
href={`/espace-ce/carbets/${carbet.id}`}
className="truncate text-lg font-medium text-zinc-900 hover:text-emerald-700"
>
{carbet.title}
</Link>
<span
className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${STATUS_STYLES[carbet.status]}`}
>
{STATUS_LABELS[carbet.status]}
</span>
</div>
<p className="mt-1 text-sm text-zinc-500">
{carbet.river} · {carbet._count.media} média{carbet._count.media > 1 ? "s" : ""}
{" · "}créé par {carbet.owner.firstName} {carbet.owner.lastName}
</p>
</div>
<div className="flex flex-shrink-0 items-center gap-2">
<Link
href={`/espace-ce/carbets/${carbet.id}`}
className="rounded-md border border-zinc-300 px-3 py-1.5 text-sm text-zinc-700 hover:bg-zinc-50"
>
Éditer
</Link>
{org.approved && carbet.status !== CarbetStatus.PUBLISHED ? (
<form action={setCarbetStatus}>
<input type="hidden" name="carbetId" value={carbet.id} />
<input type="hidden" name="status" value={CarbetStatus.PUBLISHED} />
<button
type="submit"
className="rounded-md bg-emerald-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-emerald-700"
>
Publier
</button>
</form>
) : null}
{carbet.status === CarbetStatus.PUBLISHED ? (
<form action={setCarbetStatus}>
<input type="hidden" name="carbetId" value={carbet.id} />
<input type="hidden" name="status" value={CarbetStatus.DRAFT} />
<button
type="submit"
className="rounded-md border border-zinc-300 px-3 py-1.5 text-sm text-zinc-700 hover:bg-zinc-50"
>
Dépublier
</button>
</form>
) : null}
<form action={deleteCarbet}>
<input type="hidden" name="carbetId" value={carbet.id} />
<button
type="submit"
className="rounded-md border border-red-200 px-3 py-1.5 text-sm text-red-600 hover:bg-red-50"
>
Supprimer
</button>
</form>
</div>
</li>
))}
</ul>
)}
</main>
);
}

View file

@ -0,0 +1,8 @@
import { requirePluginOr404 } from "@/lib/plugins/guard";
import { requireCeManagerSession } from "@/lib/ce-access";
export default async function CeLayout({ children }: { children: React.ReactNode }) {
await requirePluginOr404("ce-management");
await requireCeManagerSession();
return <>{children}</>;
}

View file

@ -0,0 +1,66 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { UserRole } from "@/generated/prisma/enums";
import { recordAudit } from "@/lib/admin/audit";
import { getCurrentCeOrganization } from "@/lib/ce-access";
import { prisma } from "@/lib/prisma";
/**
* Active la location matériel pour un CE : crée le RentalProvider lié à son
* organizationId. Approuvé automatiquement si l'org elle-même est approuvée.
* - Si un provider existe déjà pour cette org : redirige sans rien créer.
* - Bloque si l'org n'est pas validée (la création doit attendre l'approval).
*/
export async function activateRentalProviderForCeAction(): Promise<void> {
const session = await auth();
if (!session?.user?.id) redirect("/connexion?next=/espace-ce/materiel");
if (session.user.role !== UserRole.CE_MANAGER && session.user.role !== UserRole.ADMIN) {
redirect("/");
}
const org = await getCurrentCeOrganization();
if (!org) redirect("/espace-ce");
if (!org.approved) {
// L'org doit être validée avant activation. La page affichera la bannière.
redirect("/espace-ce/materiel?activateError=pending");
}
const existing = await prisma.rentalProvider.findFirst({
where: { organizationId: org.id },
select: { id: true },
});
if (existing) {
redirect("/espace-ce/materiel");
}
const created = await prisma.rentalProvider.create({
data: {
name: `Matériel — ${org.name}`,
isSystemD: false,
managedByUserId: session.user.id,
organizationId: org.id,
contactEmail: org.contactEmail,
rivers: [],
commissionPct: 10,
active: true,
approved: true,
approvedAt: new Date(),
approvedBy: session.user.email ?? "system",
},
select: { id: true, name: true },
});
await recordAudit({
scope: "ce",
event: "ce.rental_provider.activate",
target: created.id,
actorEmail: session.user.email ?? null,
details: { organizationId: org.id, name: created.name },
});
revalidatePath("/espace-ce/materiel");
redirect("/espace-ce/materiel");
}

View file

@ -0,0 +1,122 @@
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { MediaUploader } from "@/components/MediaUploader";
import {
getCurrentRentalProvider,
requireRentalProviderSession,
} from "@/lib/rental-access";
import { RENTAL_CATEGORY_LABEL } from "@/lib/rental-category-labels";
import { getHostItem } from "@/lib/rental-host";
import {
addItemBlockAction,
deleteHostItemAction,
removeItemBlockAction,
updateHostItemAction,
} from "../../../../espace-prestataire/actions";
import { HostItemForm } from "../../../../espace-prestataire/items/_components/ItemForm";
import { ItemBlocksManager } from "../../../../espace-prestataire/items/[itemId]/_components/ItemBlocksManager";
import { ItemInlineDelete } from "../../../../espace-prestataire/items/[itemId]/_components/ItemInlineDelete";
export const dynamic = "force-dynamic";
type PageProps = { params: Promise<{ itemId: string }> };
export default async function EditCeItemPage({ params }: PageProps) {
await requireRentalProviderSession();
const provider = await getCurrentRentalProvider();
if (!provider) redirect("/espace-ce/materiel");
const { itemId } = await params;
const item = await getHostItem(provider.id, itemId);
if (!item) notFound();
const updateThis = async (fd: FormData) => {
"use server";
return await updateHostItemAction(itemId, fd);
};
const deleteThis = async () => {
"use server";
return await deleteHostItemAction(itemId);
};
const addBlockThis = async (fd: FormData) => {
"use server";
return await addItemBlockAction(itemId, fd);
};
const removeBlockThis = async (blockId: string) => {
"use server";
return await removeItemBlockAction(blockId);
};
return (
<main className="mx-auto max-w-4xl px-6 py-10 space-y-6">
<header className="flex flex-wrap items-end justify-between gap-3">
<div>
<Link href="/espace-ce/materiel/items" className="text-xs text-zinc-500 hover:text-zinc-900">
Mes items
</Link>
<h1 className="mt-1 text-2xl font-semibold text-zinc-900">{item.name}</h1>
<p className="mt-1 text-sm text-zinc-500">
{RENTAL_CATEGORY_LABEL[item.category]} · Stock : {item.totalQty} · {item._count.lines}{" "}
location(s) historique
</p>
</div>
<ItemInlineDelete deleteAction={deleteThis} canDelete={item._count.lines === 0} />
</header>
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">
Photos & vidéos
</h2>
<MediaUploader
scope={{ kind: "rental-item", itemId: item.id }}
initialMedia={item.media}
/>
</section>
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<HostItemForm
action={updateThis}
submitLabel="Enregistrer les modifications"
initial={{
category: item.category,
name: item.name,
description: item.description,
imageUrl: item.imageUrl,
pricePerDay: item.pricePerDay.toString(),
pricePerWeek: item.pricePerWeek?.toString() ?? null,
deposit: item.deposit.toString(),
totalQty: item.totalQty,
withMotor: item.withMotor,
fuelIncluded: item.fuelIncluded,
requiresLicense: item.requiresLicense,
active: item.active,
}}
/>
</section>
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">
Calendrier de disponibilité
</h2>
<p className="mb-3 text-xs text-zinc-600">
Bloquez ici des dates pour maintenance, indisponibilité personnelle, etc. Les réservations
confirmées sont gérées automatiquement.
</p>
<ItemBlocksManager
blocks={item.availabilities.map((a) => ({
id: a.id,
startDate: a.startDate.toISOString().slice(0, 10),
endDate: a.endDate.toISOString().slice(0, 10),
qty: a.qty,
reason: a.reason,
isBooking: Boolean(a.rentalBookingId),
}))}
addAction={addBlockThis}
removeAction={removeBlockThis}
totalQty={item.totalQty}
/>
</section>
</main>
);
}

View file

@ -0,0 +1,27 @@
import Link from "next/link";
import { requireRentalProviderSession } from "@/lib/rental-access";
import { createHostItemAction } from "../../../../espace-prestataire/actions";
import { HostItemForm } from "../../../../espace-prestataire/items/_components/ItemForm";
export const dynamic = "force-dynamic";
export default async function NewCeItemPage() {
await requireRentalProviderSession();
return (
<main className="mx-auto max-w-3xl px-6 py-10">
<Link href="/espace-ce/materiel/items" className="text-xs text-zinc-500 hover:text-zinc-900">
Mes items
</Link>
<h1 className="mt-1 text-2xl font-semibold text-zinc-900">Nouvel item</h1>
<section className="mt-5 rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<HostItemForm
action={createHostItemAction}
submitLabel="Créer l'item"
initial={{ active: true, totalQty: 1 }}
/>
</section>
</main>
);
}

View file

@ -0,0 +1,109 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { RENTAL_CATEGORY_LABEL } from "@/lib/rental-category-labels";
import {
getCurrentRentalProvider,
requireRentalProviderSession,
} from "@/lib/rental-access";
import { listHostItems } from "@/lib/rental-host";
export const dynamic = "force-dynamic";
export const metadata = { title: "Items rental CE — Karbé" };
export default async function CeMaterielItemsPage() {
await requireRentalProviderSession();
const provider = await getCurrentRentalProvider();
// Sans provider activé → renvoie sur l'onboarding /espace-ce/materiel
if (!provider) redirect("/espace-ce/materiel");
const items = await listHostItems(provider.id);
return (
<main className="mx-auto max-w-6xl px-6 py-10">
<header className="mb-5 flex flex-wrap items-end justify-between gap-3">
<div>
<Link href="/espace-ce/materiel" className="text-xs text-zinc-500 hover:text-zinc-900">
Dashboard matériel CE
</Link>
<h1 className="mt-1 text-2xl font-semibold text-zinc-900">
Items locables {provider.name}
</h1>
<p className="mt-1 text-sm text-zinc-500">
{items.length} item{items.length > 1 ? "s" : ""}
</p>
</div>
<Link
href="/espace-ce/materiel/items/new"
className="rounded-md bg-emerald-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-emerald-700"
>
+ Nouvel item
</Link>
</header>
{items.length === 0 ? (
<div className="rounded-lg border border-dashed border-zinc-300 px-6 py-12 text-center text-sm text-zinc-500">
Pas encore d&apos;item.{" "}
<Link href="/espace-ce/materiel/items/new" className="text-emerald-700 underline">
Créer mon premier item
</Link>
</div>
) : (
<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">Catégorie</th>
<th className="px-4 py-2 text-right font-semibold">/j</th>
<th className="px-4 py-2 text-right font-semibold">Stock</th>
<th className="px-4 py-2 text-right font-semibold">Caution</th>
<th className="px-4 py-2 text-right font-semibold">Résa</th>
<th className="px-4 py-2 text-left font-semibold">État</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-100">
{items.map((i) => (
<tr key={i.id} className="hover:bg-zinc-50">
<td className="px-4 py-2">
<Link
href={`/espace-ce/materiel/items/${i.id}`}
className="font-medium text-zinc-900 hover:underline"
>
{i.name}
</Link>
<div className="text-[11px] text-zinc-500">
{i.withMotor ? "⚙️ moteur · " : ""}
{i.requiresLicense ? "🪪 permis · " : ""}
{i.fuelIncluded ? "⛽ essence " : ""}
</div>
</td>
<td className="px-4 py-2 text-zinc-700">{RENTAL_CATEGORY_LABEL[i.category]}</td>
<td className="px-4 py-2 text-right font-mono text-zinc-700">
{Number(i.pricePerDay).toFixed(0)}
</td>
<td className="px-4 py-2 text-right font-mono text-zinc-700">{i.totalQty}</td>
<td className="px-4 py-2 text-right font-mono text-zinc-700">
{Number(i.deposit).toFixed(0)}
</td>
<td className="px-4 py-2 text-right font-mono text-zinc-700">{i._count.lines}</td>
<td className="px-4 py-2">
{i.active ? (
<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">
Actif
</span>
) : (
<span className="rounded-full bg-zinc-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-zinc-500 ring-1 ring-inset ring-zinc-300">
Inactif
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</main>
);
}

View file

@ -0,0 +1,152 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { getCurrentCeOrganization } from "@/lib/ce-access";
import { isPluginEnabled } from "@/lib/plugins/server";
import {
getCurrentRentalProviderForCe,
} from "@/lib/rental-access";
import { getHostRentalKpis } from "@/lib/rental-host";
import { activateRentalProviderForCeAction } from "./actions";
export const dynamic = "force-dynamic";
export const metadata = { title: "Matériel CE — Karbé" };
function fmtEur(amount: string | number): string {
return Number(amount).toLocaleString("fr-FR", { style: "currency", currency: "EUR" });
}
export default async function CeMaterielPage() {
// Soft dependency : si le plugin gear-rental est off, on masque /espace-ce/materiel
// (le bouton du dashboard a déjà été désactivé côté UX).
if (!(await isPluginEnabled("gear-rental"))) {
return (
<main className="mx-auto max-w-3xl px-6 py-12">
<h1 className="text-3xl font-semibold text-zinc-900">Matériel rental</h1>
<p className="mt-4 rounded-md border border-zinc-200 bg-zinc-50 px-4 py-3 text-sm text-zinc-700">
La marketplace location matériel n&apos;est pas activée. Activez le plugin
<code className="ml-1 rounded bg-zinc-200 px-1.5 py-0.5 text-xs">gear-rental</code>{" "}
dans <Link href="/admin/plugins" className="underline">/admin/plugins</Link>.
</p>
</main>
);
}
const org = await getCurrentCeOrganization();
if (!org) redirect("/admin/organizations");
const provider = await getCurrentRentalProviderForCe(org.id);
// Onboarding : pas encore de provider activé
if (!provider) {
return (
<main className="mx-auto max-w-3xl px-6 py-12">
<Link href="/espace-ce" className="text-xs text-zinc-500 hover:text-zinc-900">
Tableau de bord CE
</Link>
<h1 className="mt-1 text-3xl font-semibold text-zinc-900">Matériel rental</h1>
<p className="mt-2 text-sm text-zinc-600">
Activez la location matériel pour proposer hamacs, kayaks, pirogues, etc. à vos
membres et au public touriste. Le provider sera créé au nom de votre CE.
</p>
{!org.approved ? (
<p className="mt-6 rounded-md border border-amber-200 bg-amber-50/60 px-4 py-3 text-sm text-amber-900">
🕒 Votre organisation est en attente de validation. La location matériel sera
activable dès qu&apos;un admin Karbé aura validé votre CE.
</p>
) : (
<form action={activateRentalProviderForCeAction} className="mt-8">
<button
type="submit"
className="rounded-md bg-emerald-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-emerald-700"
>
Activer la location matériel pour {org.name}
</button>
<p className="mt-2 text-xs text-zinc-500">
Vous pourrez ensuite ajouter vos items (hamac, pirogue, kayak). Commission
par défaut : 10 % (ajustable par un admin Karbé).
</p>
</form>
)}
</main>
);
}
// Provider existant : dashboard + KPIs
const kpis = await getHostRentalKpis(provider.id);
return (
<main className="mx-auto max-w-5xl px-6 py-10 space-y-6">
<header>
<Link href="/espace-ce" className="text-xs text-zinc-500 hover:text-zinc-900">
Tableau de bord CE
</Link>
<h1 className="mt-1 text-3xl font-semibold text-zinc-900">
Matériel rental {provider.name}
</h1>
<p className="mt-1 text-sm text-zinc-500">
Commission Karbé : {Number(provider.commissionPct).toFixed(1)} % · Géré par {org.name}
</p>
</header>
<section className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<KpiCard label="Items actifs" value={kpis.itemsActive} />
<KpiCard label="Réservations en attente" value={kpis.bookingsPending} />
<KpiCard label="Confirmées à venir" value={kpis.bookingsConfirmed} />
<KpiCard label="Revenu 30j" value={fmtEur(kpis.revenue30d)} />
</section>
<section className="grid gap-3 sm:grid-cols-2">
<ActionCard
href="/espace-ce/materiel/items"
title="Mes items"
description={
kpis.itemsActive > 0
? `${kpis.itemsActive} item${kpis.itemsActive > 1 ? "s" : ""} en location.`
: "Ajoutez votre premier item (hamac, kayak, pirogue…)."
}
/>
<ActionCard
href="/espace-ce/materiel/reservations"
title="Réservations"
description={
kpis.bookingsPending > 0
? `${kpis.bookingsPending} demande${kpis.bookingsPending > 1 ? "s" : ""} à préparer.`
: "Suivez vos réservations en cours, à préparer et terminées."
}
/>
</section>
</main>
);
}
function KpiCard({ label, value }: { label: string; value: string | number }) {
return (
<div className="rounded-lg border border-zinc-200 bg-white px-4 py-3 shadow-sm">
<div className="text-[10px] uppercase tracking-wider text-zinc-500">{label}</div>
<div className="mt-1 text-2xl font-semibold text-zinc-900 font-mono">{value}</div>
</div>
);
}
function ActionCard({
href,
title,
description,
}: {
href: string;
title: string;
description: string;
}) {
return (
<Link
href={href}
className="rounded-lg border border-zinc-200 bg-white px-5 py-4 shadow-sm transition hover:border-zinc-400 hover:shadow"
>
<h3 className="text-base font-semibold text-zinc-900">{title}</h3>
<p className="mt-1 text-sm text-zinc-600">{description}</p>
</Link>
);
}

View file

@ -0,0 +1,150 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { RentalBookingStatus } from "@/generated/prisma/enums";
import { RENTAL_STATUS_LABEL } from "@/lib/admin/rental-bookings";
import {
getCurrentRentalProvider,
requireRentalProviderSession,
} from "@/lib/rental-access";
import { listHostBookings } from "@/lib/rental-host";
import { BookingDecision } from "../../../espace-prestataire/reservations/_components/BookingDecision";
export const dynamic = "force-dynamic";
export const metadata = { title: "Réservations matériel CE — Karbé" };
const STATUS_VALUES = new Set<string>([
RentalBookingStatus.PENDING,
RentalBookingStatus.CONFIRMED,
RentalBookingStatus.HANDED_OVER,
RentalBookingStatus.RETURNED,
RentalBookingStatus.CANCELLED,
]);
type PageProps = {
searchParams: Promise<{ status?: string }>;
};
const dateFmt = new Intl.DateTimeFormat("fr-FR", {
day: "2-digit",
month: "short",
year: "2-digit",
});
export default async function CeReservationsPage({ searchParams }: PageProps) {
await requireRentalProviderSession();
const provider = await getCurrentRentalProvider();
if (!provider) redirect("/espace-ce/materiel");
const sp = await searchParams;
const status = STATUS_VALUES.has(sp.status ?? "")
? (sp.status as RentalBookingStatus)
: undefined;
const bookings = await listHostBookings(provider.id, { status });
return (
<main className="mx-auto max-w-6xl px-6 py-10">
<header className="mb-5 flex flex-wrap items-end justify-between gap-3">
<div>
<Link href="/espace-ce/materiel" className="text-xs text-zinc-500 hover:text-zinc-900">
Dashboard matériel CE
</Link>
<h1 className="mt-1 text-2xl font-semibold text-zinc-900">Réservations</h1>
<p className="mt-1 text-sm text-zinc-500">
{bookings.length} résultat{bookings.length > 1 ? "s" : ""}
</p>
</div>
<form method="get" className="flex items-center gap-2 text-sm">
<select
name="status"
defaultValue={status ?? ""}
className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-emerald-500 focus:outline-none"
>
<option value="">Tous statuts</option>
{Object.values(RentalBookingStatus).map((s) => (
<option key={s} value={s}>
{RENTAL_STATUS_LABEL[s]}
</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>
</form>
</header>
{bookings.length === 0 ? (
<div className="rounded-lg border border-dashed border-zinc-300 px-6 py-12 text-center text-sm text-zinc-500">
Aucune réservation matériel.
</div>
) : (
<ul className="space-y-3">
{bookings.map((b) => (
<li key={b.id} className="rounded-lg border border-zinc-200 bg-white p-4 shadow-sm">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<div>
<h2 className="text-base font-semibold text-zinc-900">
{b.tenant.firstName} {b.tenant.lastName}
</h2>
<p className="text-xs text-zinc-500">
{b.tenant.email}
{b.tenant.phone ? ` · ${b.tenant.phone}` : ""}
</p>
{b.booking ? (
<p className="mt-0.5 text-xs text-emerald-700">
🏠 Lié à la résa carbet :{" "}
<Link href={`/reservations/${b.booking.id}`} className="underline">
{b.booking.carbet.title}
</Link>
</p>
) : (
<p className="mt-0.5 text-xs text-zinc-500">
Location standalone (sans carbet)
</p>
)}
</div>
<div className="text-right">
<div className="text-xs text-zinc-500">
{dateFmt.format(b.startDate)} {dateFmt.format(b.endDate)}
</div>
<div className="font-mono text-base font-semibold text-zinc-900">
{Number(b.amount).toFixed(2)} {b.currency}
</div>
</div>
</div>
<ul className="mt-2 space-y-1 border-t border-zinc-100 pt-2 text-sm text-zinc-700">
{b.lines.map((l) => (
<li key={l.id} className="flex items-center justify-between">
<span>
{l.qty}× <strong>{l.item.name}</strong>
</span>
<span className="font-mono text-xs text-zinc-600">
{Number(l.lineTotal).toFixed(2)}
</span>
</li>
))}
</ul>
<div className="mt-3 flex flex-wrap items-center justify-between gap-2 border-t border-zinc-100 pt-2">
<div className="flex flex-wrap items-center gap-2 text-xs">
<span className="rounded-full bg-zinc-100 px-2 py-0.5 font-semibold uppercase tracking-wider text-zinc-700 ring-1 ring-inset ring-zinc-300">
{RENTAL_STATUS_LABEL[b.status]}
</span>
<span className="rounded-full bg-zinc-100 px-2 py-0.5 font-semibold uppercase tracking-wider text-zinc-700 ring-1 ring-inset ring-zinc-300">
{b.paymentStatus}
</span>
</div>
<BookingDecision bookingId={b.id} status={b.status} />
</div>
</li>
))}
</ul>
)}
</main>
);
}

View file

@ -0,0 +1,90 @@
"use client";
import { useState, useTransition } from "react";
import type { CreateInviteResult } from "../actions";
export function InviteForm({
action,
siteUrl,
}: {
action: (fd: FormData) => Promise<CreateInviteResult>;
siteUrl: string;
}) {
const [pending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const [link, setLink] = useState<string | null>(null);
const [emailSent, setEmailSent] = useState(false);
function onSubmit(fd: FormData) {
setError(null);
setLink(null);
setEmailSent(false);
const emailValue = ((fd.get("email") as string | null) ?? "").trim();
startTransition(async () => {
const res = await action(fd);
if (!res.ok) {
setError(res.error);
return;
}
setLink(`${siteUrl}/inscription?invite=${res.token}`);
setEmailSent(Boolean(emailValue));
});
}
return (
<div className="space-y-3">
<form action={onSubmit} className="flex flex-wrap items-end gap-2">
<label className="block grow">
<span className="text-xs text-zinc-600">Email du futur CE_MEMBER (optionnel)</span>
<input
type="email"
name="email"
placeholder="prenom.nom@entreprise.gf"
maxLength={200}
className="mt-0.5 block w-full rounded-md border border-zinc-300 px-3 py-2 text-sm focus:border-zinc-900 focus:outline-none"
/>
</label>
<button
type="submit"
disabled={pending}
className="rounded-md bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-60"
>
{pending ? "Génération…" : "Générer un lien"}
</button>
</form>
{error ? (
<div className="rounded border border-rose-200 bg-rose-50 px-3 py-2 text-xs text-rose-700">
{error}
</div>
) : null}
{link ? (
<div className="rounded-md border border-emerald-200 bg-emerald-50/60 p-3 text-sm">
<p className="text-xs font-semibold uppercase tracking-wider text-emerald-800">
Lien d&apos;invitation généré (valable 14 jours)
{emailSent ? " · email envoyé" : ""}
</p>
<code className="mt-1 block break-all rounded bg-white px-2 py-1.5 font-mono text-xs text-zinc-700">
{link}
</code>
<button
type="button"
onClick={() => {
if (typeof navigator !== "undefined" && navigator.clipboard) {
navigator.clipboard.writeText(link).catch(() => {});
}
}}
className="mt-2 rounded border border-emerald-300 bg-white px-2 py-1 text-[11px] text-emerald-800 hover:bg-emerald-100"
>
Copier
</button>
</div>
) : null}
<p className="text-[11px] text-zinc-500">
Si vous indiquez un email, l&apos;invitation sera envoyée automatiquement et le lien
sera bloqué pour toute autre adresse à la connexion. Sans email, n&apos;importe qui
ayant le lien peut rejoindre votre CE.
</p>
</div>
);
}

View file

@ -0,0 +1,82 @@
"use server";
import { revalidatePath } from "next/cache";
import { auth } from "@/auth";
import { UserRole } from "@/generated/prisma/enums";
import { recordAudit } from "@/lib/admin/audit";
import {
createOrgInviteToken,
revokeOrgInviteToken,
} from "@/lib/ce-invites";
import { getCurrentCeOrganization } from "@/lib/ce-access";
import { sendCeInviteEmail } from "@/lib/email";
import { prisma } from "@/lib/prisma";
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://karbe.cosmolan.fr";
export type CreateInviteResult =
| { ok: true; token: string }
| { ok: false; error: string };
export async function createInviteAction(fd: FormData): Promise<CreateInviteResult> {
const session = await auth();
if (!session?.user?.id) return { ok: false, error: "Non authentifié." };
if (session.user.role !== UserRole.CE_MANAGER && session.user.role !== UserRole.ADMIN) {
return { ok: false, error: "Réservé aux CE_MANAGER." };
}
const org = await getCurrentCeOrganization();
if (!org) return { ok: false, error: "Aucune organisation détectée." };
if (!org.approved) return { ok: false, error: "Votre organisation doit être validée." };
const email = ((fd.get("email") as string | null) ?? "").trim().toLowerCase() || null;
if (email && !/^[^@\s]+@[^@\s.]+\.[^@\s]+$/.test(email)) {
return { ok: false, error: "Email invalide." };
}
const token = await createOrgInviteToken({
organizationId: org.id,
createdByUserId: session.user.id,
email,
});
await recordAudit({
scope: "ce.invite",
event: "invite.create",
target: org.id,
actorEmail: session.user.email ?? null,
details: { email, emailedAutomatically: Boolean(email) },
});
// Envoi automatique si email destinataire fourni (best-effort, dry-run sans Resend).
if (email) {
const inviteUrl = `${SITE_URL}/inscription?invite=${token}`;
try {
await sendCeInviteEmail(email, org.name, inviteUrl, session.user.name);
} catch (e) {
console.error("[ce.invite] email send failed:", e instanceof Error ? e.message : e);
}
}
revalidatePath("/espace-ce/membres");
return { ok: true, token };
}
export async function revokeInviteAction(tokenHash: string): Promise<void> {
const session = await auth();
if (!session?.user?.id) return;
if (session.user.role !== UserRole.CE_MANAGER && session.user.role !== UserRole.ADMIN) return;
const org = await getCurrentCeOrganization();
if (!org) return;
const invite = await prisma.orgInviteToken.findUnique({
where: { tokenHash },
select: { organizationId: true },
});
if (!invite || invite.organizationId !== org.id) return;
await revokeOrgInviteToken(tokenHash);
await recordAudit({
scope: "ce.invite",
event: "invite.revoke",
target: org.id,
actorEmail: session.user.email ?? null,
details: {},
});
revalidatePath("/espace-ce/membres");
}

View file

@ -0,0 +1,173 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { UserRole } from "@/generated/prisma/enums";
import { getCurrentCeOrganization } from "@/lib/ce-access";
import { listOrgInviteTokens } from "@/lib/ce-invites";
import { prisma } from "@/lib/prisma";
import { createInviteAction, revokeInviteAction } from "./actions";
import { InviteForm } from "./_components/InviteForm";
export const dynamic = "force-dynamic";
export const metadata = { title: "Membres CE — Karbé" };
const ROLE_LABEL: Record<string, string> = {
CE_MANAGER: "Manager",
CE_MEMBER: "Membre",
};
export default async function CeMembresPage() {
const org = await getCurrentCeOrganization();
if (!org) redirect("/admin/organizations");
const [members, invites] = await Promise.all([
prisma.user.findMany({
where: {
organizationId: org.id,
role: { in: [UserRole.CE_MANAGER, UserRole.CE_MEMBER] },
isActive: true,
},
orderBy: [{ role: "asc" }, { lastName: "asc" }],
select: {
id: true,
email: true,
firstName: true,
lastName: true,
role: true,
createdAt: true,
},
}),
listOrgInviteTokens(org.id),
]);
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "https://karbe.cosmolan.fr";
const dateFmt = new Intl.DateTimeFormat("fr-FR", {
day: "2-digit",
month: "short",
year: "2-digit",
});
return (
<main className="mx-auto max-w-4xl px-6 py-10 space-y-6">
<header>
<Link href="/espace-ce" className="text-xs text-zinc-500 hover:text-zinc-900">
Tableau de bord CE
</Link>
<h1 className="mt-1 text-3xl font-semibold text-zinc-900">
Membres {org.name}
</h1>
<p className="mt-1 text-sm text-zinc-600">
{members.length} membre{members.length > 1 ? "s" : ""} actif{members.length > 1 ? "s" : ""}.
Générez un lien d&apos;invitation pour qu&apos;un nouveau CE_MEMBER s&apos;inscrive et
rejoigne automatiquement votre organisation.
</p>
</header>
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="text-sm font-semibold uppercase tracking-wider text-zinc-500">
Inviter un membre
</h2>
{!org.approved ? (
<p className="mt-3 text-sm text-amber-900">
🕒 La génération d&apos;invitations est bloquée tant que votre organisation n&apos;est
pas validée.
</p>
) : (
<div className="mt-3">
<InviteForm action={createInviteAction} siteUrl={siteUrl} />
</div>
)}
</section>
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">
Membres ({members.length})
</h2>
{members.length === 0 ? (
<p className="text-sm text-zinc-500">Aucun membre actif pour l&apos;instant.</p>
) : (
<ul className="divide-y divide-zinc-100">
{members.map((m) => (
<li key={m.id} className="flex items-center justify-between gap-3 py-2 text-sm">
<div>
<div className="font-medium text-zinc-900">
{m.firstName} {m.lastName}
</div>
<div className="text-xs text-zinc-500">{m.email}</div>
</div>
<span
className={
"rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider " +
(m.role === "CE_MANAGER"
? "bg-emerald-100 text-emerald-800 ring-1 ring-inset ring-emerald-300"
: "bg-zinc-100 text-zinc-700 ring-1 ring-inset ring-zinc-300")
}
>
{ROLE_LABEL[m.role] ?? m.role}
</span>
</li>
))}
</ul>
)}
</section>
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">
Invitations en cours ({invites.filter((i) => !i.usedAt && i.expiresAt > new Date()).length})
</h2>
{invites.length === 0 ? (
<p className="text-sm text-zinc-500">Aucune invitation envoyée pour l&apos;instant.</p>
) : (
<ul className="divide-y divide-zinc-100">
{invites.map((inv) => {
const expired = inv.expiresAt < new Date();
const used = inv.usedAt !== null;
const status = used ? "consommé" : expired ? "expiré" : "actif";
return (
<li
key={inv.tokenHash}
className="flex items-center justify-between gap-3 py-2 text-sm"
>
<div>
<div className="font-mono text-xs text-zinc-700">
{inv.email ?? "(lien partagé)"}
</div>
<div className="text-[11px] text-zinc-500">
Créé {dateFmt.format(inv.createdAt)} · Expire {dateFmt.format(inv.expiresAt)}
</div>
</div>
<div className="flex items-center gap-2">
<span
className={
"rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider " +
(status === "actif"
? "bg-emerald-100 text-emerald-800 ring-1 ring-inset ring-emerald-300"
: status === "consommé"
? "bg-zinc-100 text-zinc-600 ring-1 ring-inset ring-zinc-300"
: "bg-amber-100 text-amber-800 ring-1 ring-inset ring-amber-300")
}
>
{status}
</span>
{!used && !expired ? (
<form action={revokeInviteAction.bind(null, inv.tokenHash)}>
<button
type="submit"
className="rounded border border-rose-200 bg-white px-2 py-0.5 text-[11px] text-rose-700 hover:bg-rose-50"
>
Révoquer
</button>
</form>
) : null}
</div>
</li>
);
})}
</ul>
)}
</section>
</main>
);
}

150
src/app/espace-ce/page.tsx Normal file
View file

@ -0,0 +1,150 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { getCurrentCeOrganization } from "@/lib/ce-access";
import { getCeOrgKpis } from "@/lib/ce-dashboard";
export const dynamic = "force-dynamic";
export const metadata = { title: "Espace CE — Karbé" };
function fmtEur(n: number): string {
return n.toLocaleString("fr-FR", { style: "currency", currency: "EUR" });
}
export default async function CeDashboardPage() {
const org = await getCurrentCeOrganization();
if (!org) {
// ADMIN sans organizationId ciblé : pour l'instant, renvoyer vers la liste admin.
redirect("/admin/organizations");
}
const kpis = await getCeOrgKpis(org.id);
return (
<main className="mx-auto max-w-5xl px-6 py-10 space-y-6">
<header>
<h1 className="text-3xl font-semibold text-zinc-900">
Espace CE {org.name}
</h1>
<p className="mt-1 text-sm text-zinc-500">
Dashboard de votre comité d&apos;entreprise. Co-gérez vos carbets et activez la location
de matériel pour vos membres et le public touriste.
</p>
</header>
{!org.approved ? (
<section className="rounded-lg border border-amber-200 bg-amber-50/60 px-5 py-4">
<h2 className="text-base font-semibold text-amber-900">
🕒 Votre organisation est en attente de validation
</h2>
<p className="mt-1 text-sm text-amber-900">
L&apos;équipe Karbé vérifie votre demande. Vous pouvez préparer vos carbets et items
en brouillon, mais rien ne sera publié tant que votre organisation n&apos;est pas
validée. Cela prend généralement moins de 48h. Si vous n&apos;avez pas de retour
sous 72h, contactez{" "}
<a href="mailto:contact@karbe.cosmolan.fr" className="underline">
contact@karbe.cosmolan.fr
</a>
.
</p>
</section>
) : null}
<section className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<KpiCard label="Carbets co-gérés" value={kpis.carbetsCount} />
<KpiCard label="Items matériel" value={kpis.rentalItemsCount} />
<KpiCard label="Réservations 30j" value={kpis.bookings30dCount + kpis.rentalBookings30dCount} />
<KpiCard label="Revenu 30j" value={fmtEur(kpis.revenue30d)} />
</section>
<section className="grid gap-3 sm:grid-cols-2">
<ActionCard
href="/espace-ce/carbets"
title="Mes carbets"
description={
kpis.carbetsCount > 0
? `${kpis.carbetsCount} carbet${kpis.carbetsCount > 1 ? "s" : ""} co-géré${kpis.carbetsCount > 1 ? "s" : ""} par votre CE.`
: org.approved
? "Ajoutez votre premier carbet et ouvrez-le à vos membres + au public."
: "Vous pouvez préparer vos carbets en brouillon, ils seront publiables après validation."
}
/>
<ActionCard
href="/espace-ce/materiel"
title="Matériel rental"
description={
kpis.rentalItemsCount > 0
? `${kpis.rentalItemsCount} item${kpis.rentalItemsCount > 1 ? "s" : ""} en location.`
: org.approved
? "Proposez hamacs, kayaks, pirogue… à vos membres et au public."
: "Disponible après validation de votre organisation."
}
disabled={!org.approved}
comingSoon
/>
</section>
<p className="text-xs text-zinc-500">
Voir aussi vos{" "}
<Link href="/espace-ce/analytics" className="text-zinc-700 underline hover:text-zinc-900">
analytics CA & occupation
</Link>{" "}
et gérez vos{" "}
<Link href="/espace-ce/membres" className="text-zinc-700 underline hover:text-zinc-900">
membres et invitations CE
</Link>
.
</p>
</main>
);
}
function KpiCard({ label, value }: { label: string; value: string | number }) {
return (
<div className="rounded-lg border border-zinc-200 bg-white px-4 py-3 shadow-sm">
<div className="text-[10px] uppercase tracking-wider text-zinc-500">{label}</div>
<div className="mt-1 text-2xl font-semibold text-zinc-900 font-mono">{value}</div>
</div>
);
}
function ActionCard({
href,
title,
description,
disabled,
comingSoon,
}: {
href: string;
title: string;
description: string;
disabled?: boolean;
comingSoon?: boolean;
}) {
const baseCls =
"rounded-lg border bg-white px-5 py-4 shadow-sm transition " +
(disabled
? "border-zinc-200 opacity-60"
: "border-zinc-200 hover:border-zinc-400 hover:shadow");
const inner = (
<>
<h3 className="text-base font-semibold text-zinc-900">
{title}
{comingSoon ? (
<span className="ml-2 rounded bg-zinc-100 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-zinc-600">
Bientôt
</span>
) : null}
</h3>
<p className="mt-1 text-sm text-zinc-600">{description}</p>
</>
);
if (disabled || comingSoon) {
return <div className={baseCls}>{inner}</div>;
}
return (
<Link href={href} className={baseCls}>
{inner}
</Link>
);
}

View file

@ -42,10 +42,14 @@ export default async function EditCarbetPage({
select: { id: true, type: true, s3Url: true, s3Key: true, sortOrder: true },
},
amenities: { select: { amenity: { select: { key: true } } } },
organizations: { select: { organizationId: true } },
},
});
if (!carbet || !canManageCarbet(session, carbet.ownerId)) {
if (
!carbet ||
!canManageCarbet(session, carbet.ownerId, carbet.organizations.map((o) => o.organizationId))
) {
notFound();
}

View file

@ -9,7 +9,7 @@ import { prisma } from "@/lib/prisma";
import { ensureUniqueCarbetSlug } from "@/lib/slug";
import { deleteObject } from "@/lib/storage";
import { Prisma } from "@/generated/prisma/client";
import { CarbetStatus, Electricity, RoadAccess } from "@/generated/prisma/enums";
import { CarbetStatus, Electricity, RoadAccess, UserRole } from "@/generated/prisma/enums";
import type { CarbetFormState } from "./form-types";
@ -213,6 +213,10 @@ export async function createCarbet(
const slug = await ensureUniqueCarbetSlug(data.title);
// Si CE_MANAGER : on lie automatiquement le carbet à son org via OrganizationCarbetMembership.
const isCeCreator =
session.user.role === UserRole.CE_MANAGER && Boolean(session.user.organizationId);
const carbet = await prisma.$transaction(async (tx) => {
const created = await tx.carbet.create({
data: {
@ -235,9 +239,22 @@ export async function createCarbet(
select: { id: true },
});
await syncAmenities(tx, created.id, data.amenities);
if (isCeCreator) {
await tx.organizationCarbetMembership.create({
data: {
organizationId: session.user.organizationId!,
carbetId: created.id,
addedByUserId: session.user.id,
},
});
}
return created;
});
if (isCeCreator) {
revalidatePath("/espace-ce/carbets");
redirect(`/espace-ce/carbets/${carbet.id}`);
}
revalidatePath("/espace-hote/carbets");
redirect(`/espace-hote/carbets/${carbet.id}`);
}
@ -251,10 +268,17 @@ export async function updateCarbet(
const existing = await prisma.carbet.findUnique({
where: { id: carbetId },
select: { ownerId: true, _count: { select: { media: true } } },
select: {
ownerId: true,
organizations: { select: { organizationId: true } },
_count: { select: { media: true } },
},
});
if (!existing || !canManageCarbet(session, existing.ownerId)) {
if (
!existing ||
!canManageCarbet(session, existing.ownerId, existing.organizations.map((o) => o.organizationId))
) {
return {
ok: false,
errors: { _global: "Carbet introuvable ou accès refusé." },
@ -313,10 +337,17 @@ export async function setCarbetStatus(formData: FormData): Promise<void> {
const carbet = await prisma.carbet.findUnique({
where: { id: carbetId },
select: { ownerId: true, _count: { select: { media: true } } },
select: {
ownerId: true,
organizations: { select: { organizationId: true } },
_count: { select: { media: true } },
},
});
if (!carbet || !canManageCarbet(session, carbet.ownerId)) {
if (
!carbet ||
!canManageCarbet(session, carbet.ownerId, carbet.organizations.map((o) => o.organizationId))
) {
notFound();
}
@ -340,10 +371,17 @@ export async function deleteCarbet(formData: FormData): Promise<void> {
const carbet = await prisma.carbet.findUnique({
where: { id: carbetId },
select: { ownerId: true, media: { select: { s3Key: true } } },
select: {
ownerId: true,
organizations: { select: { organizationId: true } },
media: { select: { s3Key: true } },
},
});
if (!carbet || !canManageCarbet(session, carbet.ownerId)) {
if (
!carbet ||
!canManageCarbet(session, carbet.ownerId, carbet.organizations.map((o) => o.organizationId))
) {
notFound();
}
@ -366,10 +404,17 @@ export async function reorderMedia(
const carbet = await prisma.carbet.findUnique({
where: { id: carbetId },
select: { ownerId: true, media: { select: { id: true } } },
select: {
ownerId: true,
organizations: { select: { organizationId: true } },
media: { select: { id: true } },
},
});
if (!carbet || !canManageCarbet(session, carbet.ownerId)) {
if (
!carbet ||
!canManageCarbet(session, carbet.ownerId, carbet.organizations.map((o) => o.organizationId))
) {
return { ok: false };
}
@ -396,10 +441,26 @@ export async function deleteMedia(
const media = await prisma.media.findUnique({
where: { id: mediaId },
select: { s3Key: true, carbetId: true, carbet: { select: { ownerId: true } } },
select: {
s3Key: true,
carbetId: true,
carbet: {
select: {
ownerId: true,
organizations: { select: { organizationId: true } },
},
},
},
});
if (!media || !canManageCarbet(session, media.carbet.ownerId)) {
if (
!media ||
!canManageCarbet(
session,
media.carbet.ownerId,
media.carbet.organizations.map((o) => o.organizationId),
)
) {
return { ok: false };
}

View file

@ -0,0 +1,250 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { auth } from "@/auth";
import { RentalBookingStatus, RentalCategory, UserRole } from "@/generated/prisma/enums";
import { canManageRentalProvider, getCurrentRentalProvider } from "@/lib/rental-access";
import { recordAudit } from "@/lib/admin/audit";
import { prisma } from "@/lib/prisma";
const itemSchema = z.object({
category: z.enum([
RentalCategory.SLEEP,
RentalCategory.NAVIGATION,
RentalCategory.FISHING,
RentalCategory.COOKING,
RentalCategory.SAFETY,
]),
name: z.string().trim().min(2).max(200),
description: z.string().trim().max(5000).nullable().optional(),
imageUrl: z.string().trim().url().max(500).nullable().optional(),
pricePerDay: z.coerce.number().min(0).max(10000),
pricePerWeek: z.coerce.number().min(0).max(50000).nullable().optional(),
deposit: z.coerce.number().min(0).max(10000),
totalQty: z.coerce.number().int().min(1).max(1000),
withMotor: z.boolean(),
fuelIncluded: z.boolean(),
requiresLicense: z.boolean(),
active: z.boolean(),
});
async function requireOwnedProvider(): Promise<{
providerId: string;
actorEmail: string | null;
basePath: string;
}> {
const session = await auth();
if (!session?.user?.id) throw new Error("Non authentifié");
const provider = await getCurrentRentalProvider();
if (!provider) throw new Error("Aucun provider associé");
// Un CE_MANAGER reste sous /espace-ce/materiel ; un RENTAL_PROVIDER/ADMIN
// reste sous /espace-prestataire. Les actions sont mutualisées et redirigent
// vers l'espace contextuel du user.
const basePath =
session.user.role === UserRole.CE_MANAGER ? "/espace-ce/materiel" : "/espace-prestataire";
return {
providerId: provider.id,
actorEmail: session.user.email ?? null,
basePath,
};
}
function parseItemFD(fd: FormData) {
const get = (k: string) => {
const v = (fd.get(k) as string | null) ?? "";
return v.trim() === "" ? null : v.trim();
};
return {
category: ((fd.get("category") as string | null) ?? "").trim(),
name: ((fd.get("name") as string | null) ?? "").trim(),
description: get("description"),
imageUrl: get("imageUrl"),
pricePerDay: fd.get("pricePerDay"),
pricePerWeek: get("pricePerWeek"),
deposit: fd.get("deposit") ?? "0",
totalQty: fd.get("totalQty") ?? "1",
withMotor: fd.get("withMotor") === "on",
fuelIncluded: fd.get("fuelIncluded") === "on",
requiresLicense: fd.get("requiresLicense") === "on",
active: fd.get("active") === "on",
};
}
export async function createHostItemAction(fd: FormData) {
const { providerId, actorEmail, basePath } = await requireOwnedProvider();
const parsed = itemSchema.safeParse(parseItemFD(fd));
if (!parsed.success) {
return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
}
const created = await prisma.rentalItem.create({ data: { ...parsed.data, providerId } });
await recordAudit({
scope: "host.rental-items",
event: "create",
target: created.id,
actorEmail,
details: { name: created.name, providerId },
});
revalidatePath(`${basePath}/items`);
redirect(`${basePath}/items/${created.id}`);
}
export async function updateHostItemAction(itemId: string, fd: FormData) {
const { providerId, actorEmail, basePath } = await requireOwnedProvider();
const session = await auth();
if (!(await canManageRentalProvider(session!.user.id, session?.user?.role, providerId, session?.user?.organizationId))) {
return { ok: false as const, error: "Accès refusé" };
}
const existing = await prisma.rentalItem.findUnique({ where: { id: itemId }, select: { providerId: true } });
if (!existing || existing.providerId !== providerId) {
return { ok: false as const, error: "Item introuvable." };
}
const parsed = itemSchema.safeParse(parseItemFD(fd));
if (!parsed.success) {
return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
}
await prisma.rentalItem.update({ where: { id: itemId }, data: parsed.data });
await recordAudit({
scope: "host.rental-items",
event: "update",
target: itemId,
actorEmail,
details: { name: parsed.data.name },
});
revalidatePath(`${basePath}/items`);
revalidatePath(`${basePath}/items/${itemId}`);
return { ok: true as const };
}
export async function deleteHostItemAction(itemId: string) {
const { providerId, actorEmail, basePath } = await requireOwnedProvider();
const existing = await prisma.rentalItem.findUnique({
where: { id: itemId },
select: { providerId: true, _count: { select: { lines: true } } },
});
if (!existing || existing.providerId !== providerId) {
return { ok: false as const, error: "Item introuvable." };
}
if (existing._count.lines > 0) {
return { ok: false as const, error: "Impossible : item référencé par des locations." };
}
await prisma.rentalItem.delete({ where: { id: itemId } });
await recordAudit({
scope: "host.rental-items",
event: "delete",
target: itemId,
actorEmail,
details: {},
});
revalidatePath(`${basePath}/items`);
redirect(`${basePath}/items`);
}
const blockSchema = z.object({
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
qty: z.coerce.number().int().min(1).max(1000),
reason: z.enum(["MAINTENANCE", "MANUAL_BLOCK"]),
});
export async function addItemBlockAction(itemId: string, fd: FormData) {
const { providerId, actorEmail, basePath } = await requireOwnedProvider();
const existing = await prisma.rentalItem.findUnique({ where: { id: itemId }, select: { providerId: true } });
if (!existing || existing.providerId !== providerId) {
return { ok: false as const, error: "Item introuvable." };
}
const parsed = blockSchema.safeParse({
startDate: fd.get("startDate"),
endDate: fd.get("endDate"),
qty: fd.get("qty"),
reason: fd.get("reason"),
});
if (!parsed.success) {
return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
}
const start = new Date(`${parsed.data.startDate}T00:00:00.000Z`);
const end = new Date(`${parsed.data.endDate}T00:00:00.000Z`);
if (end <= start) return { ok: false as const, error: "Date de fin doit être après début." };
await prisma.rentalItemAvailability.create({
data: {
itemId,
startDate: start,
endDate: end,
qty: parsed.data.qty,
reason: parsed.data.reason,
},
});
await recordAudit({
scope: "host.rental-items",
event: "block.add",
target: itemId,
actorEmail,
details: { ...parsed.data },
});
revalidatePath(`${basePath}/items/${itemId}`);
return { ok: true as const };
}
export async function removeItemBlockAction(blockId: string) {
const { providerId, actorEmail, basePath } = await requireOwnedProvider();
const block = await prisma.rentalItemAvailability.findUnique({
where: { id: blockId },
select: { itemId: true, rentalBookingId: true, item: { select: { providerId: true } } },
});
if (!block || block.item.providerId !== providerId) {
return { ok: false as const, error: "Blocage introuvable." };
}
if (block.rentalBookingId) {
return { ok: false as const, error: "Blocage lié à une réservation : annulez la réservation à la place." };
}
await prisma.rentalItemAvailability.delete({ where: { id: blockId } });
await recordAudit({
scope: "host.rental-items",
event: "block.remove",
target: blockId,
actorEmail,
details: { itemId: block.itemId },
});
revalidatePath(`${basePath}/items/${block.itemId}`);
return { ok: true as const };
}
const statusSchema = z.enum([
RentalBookingStatus.PENDING,
RentalBookingStatus.CONFIRMED,
RentalBookingStatus.HANDED_OVER,
RentalBookingStatus.RETURNED,
RentalBookingStatus.CANCELLED,
]);
export async function updateBookingStatusAction(bookingId: string, status: string) {
const { providerId, actorEmail, basePath } = await requireOwnedProvider();
const session = await auth();
const role = session?.user?.role;
const parsed = statusSchema.safeParse(status);
if (!parsed.success) return { ok: false as const, error: "Statut invalide." };
const existing = await prisma.rentalBooking.findUnique({
where: { id: bookingId },
select: { providerId: true },
});
if (!existing || (existing.providerId !== providerId && role !== UserRole.ADMIN)) {
return { ok: false as const, error: "Réservation introuvable." };
}
await prisma.rentalBooking.update({
where: { id: bookingId },
data: { status: parsed.data },
});
await recordAudit({
scope: "host.rental-bookings",
event: "status.update",
target: bookingId,
actorEmail,
details: { status: parsed.data },
});
revalidatePath(`${basePath}/reservations`);
return { ok: true as const };
}

View file

@ -0,0 +1,151 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
type Block = {
id: string;
startDate: string;
endDate: string;
qty: number;
reason: string;
isBooking: boolean;
};
type Props = {
blocks: Block[];
totalQty: number;
addAction: (fd: FormData) => Promise<{ ok: false; error: string } | { ok: true } | undefined>;
removeAction: (blockId: string) => Promise<{ ok: false; error: string } | { ok: true } | undefined>;
};
const REASON_LABEL: Record<string, string> = {
MAINTENANCE: "🔧 Maintenance",
MANUAL_BLOCK: "⛔ Blocage personnel",
RENTAL_BOOKING: "🛒 Réservation",
};
export function ItemBlocksManager({ blocks, totalQty, addAction, removeAction }: Props) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
function onAdd(fd: FormData) {
setError(null);
startTransition(async () => {
const res = await addAction(fd);
if (res && res.ok === false) setError(res.error);
router.refresh();
});
}
function onRemove(blockId: string) {
setError(null);
startTransition(async () => {
const res = await removeAction(blockId);
if (res && res.ok === false) setError(res.error);
router.refresh();
});
}
return (
<div className="space-y-4">
<form action={onAdd} className="grid grid-cols-1 gap-2 rounded-md border border-zinc-200 bg-zinc-50 p-3 sm:grid-cols-5">
<fieldset disabled={pending} className="contents">
<label className="block text-xs">
<span className="text-zinc-600">Du</span>
<input
name="startDate"
type="date"
required
className="mt-0.5 w-full rounded-md border border-zinc-300 px-2 py-1.5 text-sm"
/>
</label>
<label className="block text-xs">
<span className="text-zinc-600">Au</span>
<input
name="endDate"
type="date"
required
className="mt-0.5 w-full rounded-md border border-zinc-300 px-2 py-1.5 text-sm"
/>
</label>
<label className="block text-xs">
<span className="text-zinc-600">Quantité</span>
<input
name="qty"
type="number"
min={1}
max={totalQty}
defaultValue={totalQty}
required
className="mt-0.5 w-full rounded-md border border-zinc-300 px-2 py-1.5 text-sm"
/>
</label>
<label className="block text-xs">
<span className="text-zinc-600">Raison</span>
<select
name="reason"
defaultValue="MAINTENANCE"
className="mt-0.5 w-full rounded-md border border-zinc-300 px-2 py-1.5 text-sm"
>
<option value="MAINTENANCE">Maintenance</option>
<option value="MANUAL_BLOCK">Blocage perso</option>
</select>
</label>
<div className="flex items-end">
<button
type="submit"
className="w-full rounded-md bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
>
{pending ? "…" : "Ajouter blocage"}
</button>
</div>
</fieldset>
</form>
{error ? (
<div className="rounded border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">{error}</div>
) : null}
{blocks.length === 0 ? (
<p className="rounded border border-dashed border-zinc-200 px-3 py-6 text-center text-xs text-zinc-500">
Aucun blocage manuel. Toutes les dates sont disponibles.
</p>
) : (
<ul className="space-y-1.5">
{blocks.map((b) => (
<li
key={b.id}
className={
"flex items-center justify-between gap-3 rounded-md border px-3 py-1.5 text-sm " +
(b.isBooking ? "border-sky-200 bg-sky-50" : "border-amber-200 bg-amber-50")
}
>
<div>
<span className="font-medium text-zinc-900">
{b.startDate} {b.endDate}
</span>
<span className="ml-2 text-xs text-zinc-600">
{b.qty} unité{b.qty > 1 ? "s" : ""} · {REASON_LABEL[b.reason] ?? b.reason}
</span>
</div>
{!b.isBooking ? (
<button
type="button"
onClick={() => onRemove(b.id)}
disabled={pending}
className="text-xs font-semibold text-rose-700 hover:text-rose-900 disabled:opacity-50"
>
Supprimer
</button>
) : (
<span className="text-[10px] uppercase tracking-wider text-sky-700">Auto</span>
)}
</li>
))}
</ul>
)}
</div>
);
}

View file

@ -0,0 +1,71 @@
"use client";
import { useState, useTransition } from "react";
type Props = {
canDelete: boolean;
deleteAction: () => Promise<{ ok: true } | { ok: false; error: string } | undefined | void>;
};
export function ItemInlineDelete({ canDelete, deleteAction }: 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 deleteAction();
if (res && (res as { ok?: boolean }).ok === false) {
setError((res as { error: string }).error);
setConfirm(false);
}
});
}
if (!canDelete) {
return (
<span className="rounded border border-zinc-200 bg-zinc-50 px-2 py-1 text-[11px] text-zinc-500">
Suppression impossible item référencé par des locations
</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 ?</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
</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&apos;item
</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>
);
}

View file

@ -0,0 +1,118 @@
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { MediaUploader } from "@/components/MediaUploader";
import { requireRentalProviderSession, getCurrentRentalProvider } from "@/lib/rental-access";
import { getHostItem } from "@/lib/rental-host";
import { RENTAL_CATEGORY_LABEL } from "@/lib/rental-category-labels";
import { HostItemForm } from "../_components/ItemForm";
import { ItemBlocksManager } from "./_components/ItemBlocksManager";
import { ItemInlineDelete } from "./_components/ItemInlineDelete";
import {
addItemBlockAction,
deleteHostItemAction,
removeItemBlockAction,
updateHostItemAction,
} from "../../actions";
export const dynamic = "force-dynamic";
type PageProps = { params: Promise<{ itemId: string }> };
export default async function EditHostItemPage({ params }: PageProps) {
await requireRentalProviderSession();
const provider = await getCurrentRentalProvider();
if (!provider) redirect("/admin/rental-providers");
const { itemId } = await params;
const item = await getHostItem(provider.id, itemId);
if (!item) notFound();
const updateThis = async (fd: FormData) => {
"use server";
return await updateHostItemAction(itemId, fd);
};
const deleteThis = async () => {
"use server";
return await deleteHostItemAction(itemId);
};
const addBlockThis = async (fd: FormData) => {
"use server";
return await addItemBlockAction(itemId, fd);
};
const removeBlockThis = async (blockId: string) => {
"use server";
return await removeItemBlockAction(blockId);
};
return (
<main className="mx-auto max-w-4xl px-6 py-10 space-y-6">
<header className="flex flex-wrap items-end justify-between gap-3">
<div>
<Link href="/espace-prestataire/items" className="text-xs text-zinc-500 hover:text-zinc-900">
Mes items
</Link>
<h1 className="mt-1 text-2xl font-semibold text-zinc-900">{item.name}</h1>
<p className="mt-1 text-sm text-zinc-500">
{RENTAL_CATEGORY_LABEL[item.category]} · Stock : {item.totalQty} · {item._count.lines} location(s) historique
</p>
</div>
<ItemInlineDelete deleteAction={deleteThis} canDelete={item._count.lines === 0} />
</header>
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">
Photos & vidéos
</h2>
<MediaUploader
scope={{ kind: "rental-item", itemId: item.id }}
initialMedia={item.media}
/>
</section>
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<HostItemForm
action={updateThis}
submitLabel="Enregistrer les modifications"
initial={{
category: item.category,
name: item.name,
description: item.description,
imageUrl: item.imageUrl,
pricePerDay: item.pricePerDay.toString(),
pricePerWeek: item.pricePerWeek?.toString() ?? null,
deposit: item.deposit.toString(),
totalQty: item.totalQty,
withMotor: item.withMotor,
fuelIncluded: item.fuelIncluded,
requiresLicense: item.requiresLicense,
active: item.active,
}}
/>
</section>
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">
Calendrier de disponibilité
</h2>
<p className="mb-3 text-xs text-zinc-600">
Bloquez ici des dates pour maintenance, indisponibilité personnelle, etc. Les réservations
confirmées sont gérées automatiquement.
</p>
<ItemBlocksManager
blocks={item.availabilities.map((a) => ({
id: a.id,
startDate: a.startDate.toISOString().slice(0, 10),
endDate: a.endDate.toISOString().slice(0, 10),
qty: a.qty,
reason: a.reason,
isBooking: Boolean(a.rentalBookingId),
}))}
addAction={addBlockThis}
removeAction={removeBlockThis}
totalQty={item.totalQty}
/>
</section>
</main>
);
}

View file

@ -0,0 +1,133 @@
"use client";
import { useState, useTransition } from "react";
import { RENTAL_CATEGORY_LABEL, RENTAL_CATEGORIES } from "@/lib/rental-category-labels";
const inputCls =
"mt-0.5 w-full rounded-md border border-zinc-300 px-3 py-2 text-sm focus:border-emerald-500 focus:outline-none";
const labelCls = "block text-sm font-medium text-zinc-800";
type Props = {
initial?: {
category?: string;
name?: string;
description?: string | null;
imageUrl?: string | null;
pricePerDay?: string | number;
pricePerWeek?: string | number | null;
deposit?: string | number;
totalQty?: number;
withMotor?: boolean;
fuelIncluded?: boolean;
requiresLicense?: boolean;
active?: boolean;
};
action: (fd: FormData) => Promise<{ ok: false; error: string } | { ok: true } | undefined>;
submitLabel?: string;
};
export function HostItemForm({ 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(fd: FormData) {
setError(null);
setSuccess(null);
startTransition(async () => {
const res = await action(fd);
if (res && res.ok === false) setError(res.error);
else if (res && res.ok === true) setSuccess("Enregistré.");
});
}
return (
<form action={onSubmit} className="space-y-4">
<fieldset disabled={pending} className="space-y-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<label className="block">
<span className={labelCls}>Catégorie</span>
<select name="category" defaultValue={initial.category ?? ""} required className={inputCls}>
<option value="" disabled> sélectionner </option>
{RENTAL_CATEGORIES.map((c) => (
<option key={c} value={c}>{RENTAL_CATEGORY_LABEL[c]}</option>
))}
</select>
</label>
<label className="block">
<span className={labelCls}>Statut</span>
<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" />
Actif (visible au catalogue)
</label>
</label>
<label className="block sm:col-span-2">
<span className={labelCls}>Nom de l&apos;item</span>
<input name="name" required maxLength={200} defaultValue={initial.name ?? ""} className={inputCls} placeholder="ex. Hamac coton large" />
</label>
<label className="block sm:col-span-2">
<span className={labelCls}>Description</span>
<textarea name="description" rows={3} maxLength={5000} defaultValue={initial.description ?? ""} className={inputCls} />
</label>
<label className="block">
<span className={labelCls}>URL image</span>
<input name="imageUrl" type="url" maxLength={500} defaultValue={initial.imageUrl ?? ""} className={inputCls} />
</label>
<label className="block">
<span className={labelCls}>Stock total (qté)</span>
<input name="totalQty" type="number" min={1} max={1000} defaultValue={initial.totalQty?.toString() ?? "1"} required className={inputCls} />
</label>
<label className="block">
<span className={labelCls}>Prix / jour ()</span>
<input name="pricePerDay" type="number" min={0} step="0.5" defaultValue={initial.pricePerDay?.toString() ?? ""} required className={inputCls} />
</label>
<label className="block">
<span className={labelCls}>Prix / semaine ()</span>
<input name="pricePerWeek" type="number" min={0} step="0.5" defaultValue={initial.pricePerWeek?.toString() ?? ""} className={inputCls} />
</label>
<label className="block">
<span className={labelCls}>Caution ()</span>
<input name="deposit" type="number" min={0} step="1" defaultValue={initial.deposit?.toString() ?? "0"} className={inputCls} />
</label>
</div>
<fieldset className="rounded-lg border border-zinc-200 bg-zinc-50 p-3">
<legend className="px-1 text-xs font-semibold uppercase tracking-wider text-zinc-500">
Spécifications
</legend>
<div className="flex flex-wrap gap-4 pt-1 text-sm">
<label className="flex items-center gap-2">
<input type="checkbox" name="withMotor" defaultChecked={initial.withMotor ?? false} className="h-4 w-4 rounded border-zinc-300" />
Avec moteur
</label>
<label className="flex items-center gap-2">
<input type="checkbox" name="fuelIncluded" defaultChecked={initial.fuelIncluded ?? false} className="h-4 w-4 rounded border-zinc-300" />
Essence incluse
</label>
<label className="flex items-center gap-2">
<input type="checkbox" name="requiresLicense" defaultChecked={initial.requiresLicense ?? false} className="h-4 w-4 rounded border-zinc-300" />
Permis bateau requis
</label>
</div>
</fieldset>
{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">
<button
type="submit"
className="rounded-md bg-emerald-600 px-5 py-2 text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
>
{pending ? "Enregistrement…" : submitLabel}
</button>
</div>
</fieldset>
</form>
);
}

View file

@ -0,0 +1,23 @@
import Link from "next/link";
import { requireRentalProviderSession } from "@/lib/rental-access";
import { HostItemForm } from "../_components/ItemForm";
import { createHostItemAction } from "../../actions";
export const dynamic = "force-dynamic";
export default async function NewHostItemPage() {
await requireRentalProviderSession();
return (
<main className="mx-auto max-w-3xl px-6 py-10">
<Link href="/espace-prestataire/items" className="text-xs text-zinc-500 hover:text-zinc-900">
Mes items
</Link>
<h1 className="mt-1 text-2xl font-semibold text-zinc-900">Nouvel item</h1>
<section className="mt-5 rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<HostItemForm action={createHostItemAction} submitLabel="Créer l'item" initial={{ active: true, totalQty: 1 }} />
</section>
</main>
);
}

View file

@ -0,0 +1,93 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { requireRentalProviderSession, getCurrentRentalProvider } from "@/lib/rental-access";
import { listHostItems } from "@/lib/rental-host";
import { RENTAL_CATEGORY_LABEL } from "@/lib/rental-category-labels";
export const dynamic = "force-dynamic";
export default async function HostItemsPage() {
await requireRentalProviderSession();
const provider = await getCurrentRentalProvider();
if (!provider) redirect("/admin/rental-providers");
const items = await listHostItems(provider.id);
return (
<main className="mx-auto max-w-6xl px-6 py-10">
<header className="mb-5 flex flex-wrap items-end justify-between gap-3">
<div>
<Link href="/espace-prestataire" className="text-xs text-zinc-500 hover:text-zinc-900">
Dashboard
</Link>
<h1 className="mt-1 text-2xl font-semibold text-zinc-900">Mes items locables</h1>
<p className="mt-1 text-sm text-zinc-500">{items.length} item{items.length > 1 ? "s" : ""}</p>
</div>
<Link
href="/espace-prestataire/items/new"
className="rounded-md bg-emerald-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-emerald-700"
>
+ Nouvel item
</Link>
</header>
{items.length === 0 ? (
<div className="rounded-lg border border-dashed border-zinc-300 px-6 py-12 text-center text-sm text-zinc-500">
Pas encore d&apos;item.{" "}
<Link href="/espace-prestataire/items/new" className="text-emerald-700 underline">
Créer mon premier item
</Link>
</div>
) : (
<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">Catégorie</th>
<th className="px-4 py-2 text-right font-semibold">/j</th>
<th className="px-4 py-2 text-right font-semibold">Stock</th>
<th className="px-4 py-2 text-right font-semibold">Caution</th>
<th className="px-4 py-2 text-right font-semibold">Résa</th>
<th className="px-4 py-2 text-left font-semibold">État</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-100">
{items.map((i) => (
<tr key={i.id} className="hover:bg-zinc-50">
<td className="px-4 py-2">
<Link href={`/espace-prestataire/items/${i.id}`} className="font-medium text-zinc-900 hover:underline">
{i.name}
</Link>
<div className="text-[11px] text-zinc-500">
{i.withMotor ? "⚙️ moteur · " : ""}
{i.requiresLicense ? "🪪 permis · " : ""}
{i.fuelIncluded ? "⛽ essence " : ""}
</div>
</td>
<td className="px-4 py-2 text-zinc-700">{RENTAL_CATEGORY_LABEL[i.category]}</td>
<td className="px-4 py-2 text-right font-mono text-zinc-700">{Number(i.pricePerDay).toFixed(0)}</td>
<td className="px-4 py-2 text-right font-mono text-zinc-700">{i.totalQty}</td>
<td className="px-4 py-2 text-right font-mono text-zinc-700">{Number(i.deposit).toFixed(0)}</td>
<td className="px-4 py-2 text-right font-mono text-zinc-700">{i._count.lines}</td>
<td className="px-4 py-2">
{i.active ? (
<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">
Actif
</span>
) : (
<span className="rounded-full bg-zinc-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-zinc-500 ring-1 ring-inset ring-zinc-300">
Inactif
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</main>
);
}

View file

@ -0,0 +1,6 @@
import { requirePluginOr404 } from "@/lib/plugins/guard";
export default async function ProviderLayout({ children }: { children: React.ReactNode }) {
await requirePluginOr404("gear-rental");
return <>{children}</>;
}

View file

@ -0,0 +1,153 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { requireRentalProviderSession, getCurrentRentalProvider } from "@/lib/rental-access";
import { getHostRentalKpis } from "@/lib/rental-host";
export const dynamic = "force-dynamic";
function fmtEur(amount: string | number): string {
const n = Number(amount);
return n.toLocaleString("fr-FR", { style: "currency", currency: "EUR" });
}
const dateFmt = new Intl.DateTimeFormat("fr-FR", {
day: "2-digit",
month: "long",
year: "numeric",
});
export default async function ProviderDashboardPage() {
await requireRentalProviderSession();
const provider = await getCurrentRentalProvider();
if (!provider) {
// Admin sans providerId ciblé : redirect vers liste admin
redirect("/admin/rental-providers");
}
const kpis = await getHostRentalKpis(provider.id);
return (
<main className="mx-auto max-w-6xl px-6 py-10">
<header className="mb-6 flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="text-3xl font-semibold text-zinc-900">Espace prestataire</h1>
<p className="mt-1 text-sm text-zinc-600">
{provider.name}
{provider.isSystemD ? " · Fournisseur officiel Karbé" : ""}
</p>
</div>
<div className="flex gap-2">
<Link
href="/espace-prestataire/items/new"
className="rounded-md bg-emerald-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-emerald-700"
>
+ Nouvel item
</Link>
<Link
href="/espace-prestataire/items"
className="rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-sm text-zinc-700 hover:bg-zinc-50"
>
Mes items
</Link>
<Link
href="/espace-prestataire/reservations"
className="rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-sm text-zinc-700 hover:bg-zinc-50"
>
Réservations
</Link>
</div>
</header>
{!provider.approved ? (
<div className="mb-6 rounded-lg border border-amber-300 bg-amber-50 p-4">
<div className="text-xs uppercase tracking-wider text-amber-900">Compte en attente de validation</div>
<p className="mt-1 text-sm text-amber-900">
Vos items ne sont <strong>pas encore visibles</strong> sur le catalogue public.
L&apos;équipe Karbé contactera bientôt {provider.contactEmail ?? "votre email"} pour finaliser
votre adhésion. Vous pouvez toutefois préparer vos items dès maintenant.
</p>
</div>
) : null}
<section className="mb-6 grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
<Kpi label="CA total" value={fmtEur(kpis.revenueTotal)} />
<Kpi label="CA 30 j" value={fmtEur(kpis.revenue30d)} />
<Kpi
label="À confirmer"
value={String(kpis.bookingsPending)}
tone={kpis.bookingsPending > 0 ? "warn" : "neutral"}
/>
<Kpi label="Confirmées à venir" value={String(kpis.bookingsConfirmed)} />
<Kpi label="Items au catalogue" value={String(kpis.itemsActive)} />
<Kpi label="Items total" value={String(kpis.itemsTotal)} />
</section>
{kpis.nextHandover ? (
<section className="mb-6 rounded-lg border border-emerald-300 bg-emerald-50 p-4">
<div className="text-xs uppercase tracking-wider text-emerald-700">Prochaine remise</div>
<div className="mt-1 text-base font-semibold text-emerald-900">
{kpis.nextHandover.tenantName} · {kpis.nextHandover.lineCount} ligne(s)
</div>
<div className="text-sm text-emerald-800">
{dateFmt.format(kpis.nextHandover.startDate)}
</div>
<Link
href={`/espace-prestataire/reservations`}
className="mt-2 inline-block text-xs font-semibold text-emerald-900 underline"
>
Voir le détail
</Link>
</section>
) : null}
<section>
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">Mon activité</h2>
<ul className="space-y-2 text-sm">
<li className="rounded border border-zinc-200 bg-white px-3 py-2">
<span className="text-zinc-500">Fleuves desservis :</span>{" "}
<strong className="text-zinc-900">{provider.rivers.join(", ") || "—"}</strong>
</li>
<li className="rounded border border-zinc-200 bg-white px-3 py-2">
<span className="text-zinc-500">Commission Karbé :</span>{" "}
<strong className="text-zinc-900">{Number(provider.commissionPct).toFixed(1)}%</strong>
</li>
<li className="rounded border border-zinc-200 bg-white px-3 py-2">
<span className="text-zinc-500">Statut :</span>{" "}
<strong className="text-zinc-900">{provider.active ? "Actif" : "Inactif"}</strong>
{" · "}
<strong className="text-zinc-900">{provider.approved ? "Approuvé" : "En attente"}</strong>
</li>
</ul>
</section>
</main>
);
}
function Kpi({
label,
value,
tone = "neutral",
}: {
label: string;
value: string;
tone?: "neutral" | "warn";
}) {
return (
<div
className={
"rounded-lg border bg-white p-3 shadow-sm " +
(tone === "warn" ? "border-amber-300" : "border-zinc-200")
}
>
<div className="text-[11px] uppercase tracking-wider text-zinc-500">{label}</div>
<div
className={
"mt-1 text-xl font-semibold " + (tone === "warn" ? "text-amber-700" : "text-zinc-900")
}
>
{value}
</div>
</div>
);
}

View file

@ -0,0 +1,65 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { CancelRentalButton } from "@/components/CancelRentalButton";
import { RentalBookingStatus } from "@/generated/prisma/enums";
import { updateBookingStatusAction } from "../../actions";
const btnBase =
"rounded-md px-3 py-1.5 text-xs font-semibold transition disabled:opacity-50";
export function BookingDecision({ bookingId, status }: { bookingId: string; status: string }) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
function set(next: string) {
setError(null);
startTransition(async () => {
const res = await updateBookingStatusAction(bookingId, next);
if (res && res.ok === false) setError(res.error);
router.refresh();
});
}
return (
<div className="flex flex-wrap items-center gap-2">
{status === RentalBookingStatus.PENDING ? (
<button
type="button"
onClick={() => set(RentalBookingStatus.CONFIRMED)}
disabled={pending}
className={`${btnBase} bg-emerald-600 text-white hover:bg-emerald-700`}
>
Confirmer
</button>
) : null}
{status === RentalBookingStatus.CONFIRMED ? (
<button
type="button"
onClick={() => set(RentalBookingStatus.HANDED_OVER)}
disabled={pending}
className={`${btnBase} bg-emerald-600 text-white hover:bg-emerald-700`}
>
Marquer remis client
</button>
) : null}
{status === RentalBookingStatus.HANDED_OVER ? (
<button
type="button"
onClick={() => set(RentalBookingStatus.RETURNED)}
disabled={pending}
className={`${btnBase} bg-emerald-600 text-white hover:bg-emerald-700`}
>
Marquer retourné
</button>
) : null}
{status === RentalBookingStatus.PENDING || status === RentalBookingStatus.CONFIRMED ? (
<CancelRentalButton rentalBookingId={bookingId} label="Annuler" />
) : null}
{error ? <span className="text-xs text-rose-700">{error}</span> : null}
</div>
);
}

View file

@ -0,0 +1,137 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { RentalBookingStatus } from "@/generated/prisma/enums";
import { requireRentalProviderSession, getCurrentRentalProvider } from "@/lib/rental-access";
import { listHostBookings } from "@/lib/rental-host";
import { RENTAL_STATUS_LABEL } from "@/lib/admin/rental-bookings";
import { BookingDecision } from "./_components/BookingDecision";
export const dynamic = "force-dynamic";
const STATUS_VALUES = new Set<string>([
RentalBookingStatus.PENDING,
RentalBookingStatus.CONFIRMED,
RentalBookingStatus.HANDED_OVER,
RentalBookingStatus.RETURNED,
RentalBookingStatus.CANCELLED,
]);
type PageProps = {
searchParams: Promise<{ status?: string }>;
};
const dateFmt = new Intl.DateTimeFormat("fr-FR", {
day: "2-digit",
month: "short",
year: "2-digit",
});
export default async function HostReservationsPage({ searchParams }: PageProps) {
await requireRentalProviderSession();
const provider = await getCurrentRentalProvider();
if (!provider) redirect("/admin/rental-providers");
const sp = await searchParams;
const status = STATUS_VALUES.has(sp.status ?? "")
? (sp.status as RentalBookingStatus)
: undefined;
const bookings = await listHostBookings(provider.id, { status });
return (
<main className="mx-auto max-w-6xl px-6 py-10">
<header className="mb-5 flex flex-wrap items-end justify-between gap-3">
<div>
<Link href="/espace-prestataire" className="text-xs text-zinc-500 hover:text-zinc-900">
Dashboard
</Link>
<h1 className="mt-1 text-2xl font-semibold text-zinc-900">Réservations</h1>
<p className="mt-1 text-sm text-zinc-500">{bookings.length} résultat{bookings.length > 1 ? "s" : ""}</p>
</div>
<form method="get" className="flex items-center gap-2 text-sm">
<select
name="status"
defaultValue={status ?? ""}
className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-emerald-500 focus:outline-none"
>
<option value="">Tous statuts</option>
{Object.values(RentalBookingStatus).map((s) => (
<option key={s} value={s}>{RENTAL_STATUS_LABEL[s]}</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>
</form>
</header>
{bookings.length === 0 ? (
<div className="rounded-lg border border-dashed border-zinc-300 px-6 py-12 text-center text-sm text-zinc-500">
Aucune réservation matériel.
</div>
) : (
<ul className="space-y-3">
{bookings.map((b) => (
<li key={b.id} className="rounded-lg border border-zinc-200 bg-white p-4 shadow-sm">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<div>
<h2 className="text-base font-semibold text-zinc-900">
{b.tenant.firstName} {b.tenant.lastName}
</h2>
<p className="text-xs text-zinc-500">
{b.tenant.email}
{b.tenant.phone ? ` · ${b.tenant.phone}` : ""}
</p>
{b.booking ? (
<p className="mt-0.5 text-xs text-emerald-700">
🏠 Lié à la résa carbet :{" "}
<Link href={`/reservations/${b.booking.id}`} className="underline">
{b.booking.carbet.title}
</Link>
</p>
) : (
<p className="mt-0.5 text-xs text-zinc-500">Location standalone (sans carbet)</p>
)}
</div>
<div className="text-right">
<div className="text-xs text-zinc-500">
{dateFmt.format(b.startDate)} {dateFmt.format(b.endDate)}
</div>
<div className="font-mono text-base font-semibold text-zinc-900">
{Number(b.amount).toFixed(2)} {b.currency}
</div>
</div>
</div>
<ul className="mt-2 space-y-1 border-t border-zinc-100 pt-2 text-sm text-zinc-700">
{b.lines.map((l) => (
<li key={l.id} className="flex items-center justify-between">
<span>
{l.qty}× <strong>{l.item.name}</strong>
</span>
<span className="font-mono text-xs text-zinc-600">
{Number(l.lineTotal).toFixed(2)}
</span>
</li>
))}
</ul>
<div className="mt-3 flex flex-wrap items-center justify-between gap-2 border-t border-zinc-100 pt-2">
<div className="flex flex-wrap items-center gap-2 text-xs">
<span className="rounded-full bg-zinc-100 px-2 py-0.5 font-semibold uppercase tracking-wider text-zinc-700 ring-1 ring-inset ring-zinc-300">
{RENTAL_STATUS_LABEL[b.status]}
</span>
<span className="rounded-full bg-zinc-100 px-2 py-0.5 font-semibold uppercase tracking-wider text-zinc-700 ring-1 ring-inset ring-zinc-300">
{b.paymentStatus}
</span>
</div>
<BookingDecision bookingId={b.id} status={b.status} />
</div>
</li>
))}
</ul>
)}
</main>
);
}

View file

@ -4,13 +4,19 @@ import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { signIn } from "next-auth/react";
type Props = { next: string };
type InviteContext = { token: string; orgName: string; emailLock?: string | null };
export function SignupForm({ next }: Props) {
type Props = { next: string; invite?: InviteContext | null };
export function SignupForm({ next, invite }: Props) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const [role, setRole] = useState<"TOURIST" | "OWNER">("TOURIST");
const [role, setRole] = useState<"TOURIST" | "OWNER" | "RENTAL_PROVIDER" | "CE_MANAGER">("TOURIST");
const [providerName, setProviderName] = useState("");
const [providerRivers, setProviderRivers] = useState("");
const [orgName, setOrgName] = useState("");
const isInvite = Boolean(invite);
function onSubmit(formData: FormData) {
setError(null);
@ -24,12 +30,45 @@ export function SignupForm({ next }: Props) {
setError("Le mot de passe doit faire au moins 8 caractères.");
return;
}
if (role === "RENTAL_PROVIDER" && providerName.trim().length < 2) {
setError("Le nom de votre activité de loueur est requis.");
return;
}
if (role === "CE_MANAGER" && orgName.trim().length < 2) {
setError("Le nom de votre Comité d'Entreprise est requis.");
return;
}
startTransition(async () => {
const body: Record<string, unknown> = {
email,
password,
firstName,
lastName,
phone: phone || null,
role,
};
if (role === "RENTAL_PROVIDER") {
body.providerName = providerName.trim();
body.providerRivers = providerRivers
.split(/[,;\n]/)
.map((s) => s.trim())
.filter((s) => s.length > 0);
}
if (role === "CE_MANAGER") {
body.orgName = orgName.trim();
}
if (isInvite && invite) {
body.inviteToken = invite.token;
// L'API force le rôle CE_MEMBER quand inviteToken est valide ;
// on retire les champs inutiles pour ne pas créer de confusion.
delete (body as { providerName?: unknown }).providerName;
delete (body as { orgName?: unknown }).orgName;
}
const res = await fetch("/api/signup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password, firstName, lastName, phone: phone || null, role }),
body: JSON.stringify(body),
});
const json = await res.json().catch(() => ({}));
if (!res.ok) {
@ -69,7 +108,17 @@ export function SignupForm({ next }: Props) {
<label className="block">
<span className="text-xs text-zinc-600">Email</span>
<input name="email" type="email" required maxLength={200} className={inputCls + " mt-0.5"} />
<input
name="email"
type="email"
required
maxLength={200}
defaultValue={invite?.emailLock ?? undefined}
readOnly={Boolean(invite?.emailLock)}
className={
inputCls + " mt-0.5" + (invite?.emailLock ? " bg-zinc-50 text-zinc-700" : "")
}
/>
</label>
<label className="block">
@ -89,9 +138,16 @@ export function SignupForm({ next }: Props) {
<input name="phone" type="tel" maxLength={40} className={inputCls + " mt-0.5"} />
</label>
<fieldset className="space-y-1">
{isInvite ? (
<p className="rounded-md border border-emerald-200 bg-emerald-50/60 px-3 py-2 text-xs text-emerald-900">
Vous rejoignez <strong>{invite!.orgName}</strong> comme membre CE les autres
types de compte sont masqués.
</p>
) : null}
<fieldset className="space-y-1" hidden={isInvite}>
<legend className="text-xs text-zinc-600">Type de compte</legend>
<div className="grid grid-cols-2 gap-2 pt-1">
<div className="grid grid-cols-1 gap-2 pt-1 sm:grid-cols-2 lg:grid-cols-4">
<label
className={
"flex cursor-pointer flex-col items-start rounded-md border px-3 py-2 text-sm " +
@ -130,9 +186,99 @@ export function SignupForm({ next }: Props) {
<span className="font-semibold text-zinc-900">Hôte</span>
<span className="text-[11px] text-zinc-500">Publier un carbet.</span>
</label>
<label
className={
"flex cursor-pointer flex-col items-start rounded-md border px-3 py-2 text-sm " +
(role === "RENTAL_PROVIDER"
? "border-zinc-900 bg-zinc-50 ring-1 ring-zinc-900"
: "border-zinc-300 hover:bg-zinc-50")
}
>
<input
type="radio"
name="role"
value="RENTAL_PROVIDER"
checked={role === "RENTAL_PROVIDER"}
onChange={() => setRole("RENTAL_PROVIDER")}
className="sr-only"
/>
<span className="font-semibold text-zinc-900">Loueur matériel</span>
<span className="text-[11px] text-zinc-500">Hamac, pirogue, kayak</span>
</label>
<label
className={
"flex cursor-pointer flex-col items-start rounded-md border px-3 py-2 text-sm " +
(role === "CE_MANAGER"
? "border-zinc-900 bg-zinc-50 ring-1 ring-zinc-900"
: "border-zinc-300 hover:bg-zinc-50")
}
>
<input
type="radio"
name="role"
value="CE_MANAGER"
checked={role === "CE_MANAGER"}
onChange={() => setRole("CE_MANAGER")}
className="sr-only"
/>
<span className="font-semibold text-zinc-900">Comité d&apos;Entreprise</span>
<span className="text-[11px] text-zinc-500">Gérer les carbets et matériel d&apos;un CE.</span>
</label>
</div>
</fieldset>
{role === "CE_MANAGER" ? (
<div className="space-y-2 rounded-md border border-amber-200 bg-amber-50/40 p-3">
<p className="text-[11px] text-amber-900">
Votre Comité d&apos;Entreprise sera créé en <strong>attente de validation</strong>.
Vous pouvez vous connecter à votre espace CE dès la création mais ne publierez
vos carbets et matériel qu&apos;après validation par l&apos;équipe Karbé.
</p>
<label className="block">
<span className="text-xs text-zinc-600">Nom de votre Comité d&apos;Entreprise</span>
<input
type="text"
value={orgName}
onChange={(e) => setOrgName(e.target.value)}
placeholder="ex. CE Spatiale de Kourou"
maxLength={200}
className={inputCls + " mt-0.5"}
/>
</label>
</div>
) : null}
{role === "RENTAL_PROVIDER" ? (
<div className="space-y-2 rounded-md border border-emerald-200 bg-emerald-50/30 p-3">
<p className="text-[11px] text-emerald-900">
Votre compte sera créé en <strong>attente de validation</strong>. Un admin Karbé
vous contactera pour confirmer votre activité avant publication de vos items.
</p>
<label className="block">
<span className="text-xs text-zinc-600">Nom de votre activité</span>
<input
type="text"
value={providerName}
onChange={(e) => setProviderName(e.target.value)}
placeholder="ex. Pirogues du Bas-Oyapock"
maxLength={200}
className={inputCls + " mt-0.5"}
/>
</label>
<label className="block">
<span className="text-xs text-zinc-600">Fleuves desservis (séparés par virgule)</span>
<input
type="text"
value={providerRivers}
onChange={(e) => setProviderRivers(e.target.value)}
placeholder="Maroni, Oyapock"
maxLength={300}
className={inputCls + " mt-0.5"}
/>
</label>
</div>
) : null}
{error ? (
<div className="rounded border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">{error}</div>
) : null}

View file

@ -2,12 +2,14 @@ import { redirect } from "next/navigation";
import Link from "next/link";
import { auth } from "@/auth";
import { getOrgInviteByToken } from "@/lib/ce-invites";
import { SignupForm } from "./_components/SignupForm";
export const dynamic = "force-dynamic";
type PageProps = {
searchParams: Promise<{ next?: string }>;
searchParams: Promise<{ next?: string; invite?: string }>;
};
export default async function SignupPage({ searchParams }: PageProps) {
@ -16,17 +18,32 @@ export default async function SignupPage({ searchParams }: PageProps) {
const next = sp.next && sp.next.startsWith("/") ? sp.next : "/";
if (session?.user?.id) redirect(next);
// Si un token d'invitation valide est présent, on pré-remplit le contexte CE_MEMBER.
let invite: { token: string; orgName: string; emailLock?: string | null } | null = null;
if (sp.invite) {
const found = await getOrgInviteByToken(sp.invite);
if (found) {
invite = {
token: sp.invite,
orgName: found.organization.name,
emailLock: found.email,
};
}
}
return (
<main className="mx-auto flex min-h-[70vh] max-w-md items-center px-6 py-12">
<div className="w-full space-y-4 rounded-xl border border-zinc-200 bg-white p-6 shadow-sm">
<header>
<h1 className="text-2xl font-semibold text-zinc-900">Créer un compte</h1>
<p className="mt-1 text-sm text-zinc-500">
Un compte vous permet de réserver un séjour ou, en tant qu&apos;hôte, de publier votre carbet.
{invite
? `Vous avez été invité à rejoindre « ${invite.orgName} » comme membre CE.`
: "Un compte vous permet de réserver un séjour ou, en tant qu'hôte, de publier votre carbet."}
</p>
</header>
<SignupForm next={next} />
<SignupForm next={next} invite={invite} />
<p className="border-t border-zinc-100 pt-3 text-center text-sm text-zinc-500">
Déjà un compte ?{" "}

View file

@ -5,6 +5,8 @@ import { PluginProvider } from "@/lib/plugins/client";
import { getEnabledPluginKeys, syncPluginsFromRegistry } from "@/lib/plugins/server";
import { SeasonBanner } from "@/components/SeasonBanner";
import { SiteHeaderGuard } from "@/components/SiteHeaderGuard";
import { RentalCartProvider } from "@/components/RentalCartProvider";
import { readCartFromCookies } from "@/lib/rental-cart-server";
import { LocaleProvider } from "@/lib/i18n/client";
import { dict, getLocale } from "@/lib/i18n/server";
@ -112,6 +114,7 @@ export default async function RootLayout({
const locale = await getLocale();
const messages = await dict(locale);
const initialCart = await readCartFromCookies();
return (
<html
@ -124,9 +127,11 @@ export default async function RootLayout({
>
<PluginProvider enabledKeys={enabledKeys}>
<LocaleProvider locale={locale} messages={messages}>
<SeasonBanner />
<SiteHeaderGuard />
{children}
<RentalCartProvider initial={initialCart}>
<SeasonBanner />
<SiteHeaderGuard />
{children}
</RentalCartProvider>
</LocaleProvider>
</PluginProvider>
</body>

View file

@ -0,0 +1,120 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useCart } from "@/components/RentalCartProvider";
import { diffDays } from "@/lib/rental-cart";
type Props = {
itemId: string;
pricePerDay: number;
deposit: number;
maxQty: number;
};
function todayPlus(n: number): string {
const d = new Date();
d.setHours(0, 0, 0, 0);
d.setDate(d.getDate() + n);
return d.toISOString().slice(0, 10);
}
export function AddToCart({ itemId, pricePerDay, deposit, maxQty }: Props) {
const { addEntry, cart } = useCart();
const [start, setStart] = useState(todayPlus(7));
const [end, setEnd] = useState(todayPlus(9));
const [qty, setQty] = useState(1);
const [added, setAdded] = useState(false);
const nights = Math.max(1, diffDays(start, end));
const subtotal = nights * qty * pricePerDay;
const depositTotal = qty * deposit;
const alreadyInCart = cart.items.some(
(e) => e.itemId === itemId && e.startDate === start && e.endDate === end,
);
function onAdd() {
addEntry({ itemId, qty, startDate: start, endDate: end });
setAdded(true);
}
return (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-2 text-sm">
<label className="block">
<span className="text-xs text-zinc-500">Du</span>
<input
type="date"
value={start}
min={todayPlus(0)}
onChange={(e) => setStart(e.target.value)}
className="mt-0.5 block w-full min-h-[44px] rounded-md border border-zinc-300 px-3 py-2 text-base"
/>
</label>
<label className="block">
<span className="text-xs text-zinc-500">Au</span>
<input
type="date"
value={end}
min={start || todayPlus(1)}
onChange={(e) => setEnd(e.target.value)}
className="mt-0.5 block w-full min-h-[44px] rounded-md border border-zinc-300 px-3 py-2 text-base"
/>
</label>
</div>
<label className="block text-sm">
<span className="text-xs text-zinc-500">Quantité</span>
<input
type="number"
value={qty}
min={1}
max={maxQty}
onChange={(e) => setQty(Math.max(1, Math.min(maxQty, Number(e.target.value) || 1)))}
inputMode="numeric"
className="mt-0.5 block w-full min-h-[44px] rounded-md border border-zinc-300 px-3 py-2 text-base"
/>
</label>
<div className="border-t border-zinc-100 pt-2 text-sm text-zinc-700">
<div className="flex justify-between">
<span>
{pricePerDay.toFixed(0)} × {nights} jour{nights > 1 ? "s" : ""} × {qty}
</span>
<span className="font-mono">{subtotal.toFixed(2)} </span>
</div>
{depositTotal > 0 ? (
<div className="flex justify-between text-xs text-zinc-500">
<span>+ Caution (récupérable)</span>
<span className="font-mono">{depositTotal.toFixed(2)} </span>
</div>
) : null}
</div>
{!added ? (
<button
type="button"
onClick={onAdd}
disabled={alreadyInCart || nights === 0}
className="w-full min-h-[44px] rounded-md bg-emerald-600 px-4 py-3 text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
>
{alreadyInCart ? "Déjà dans le panier" : "Ajouter au panier"}
</button>
) : (
<div className="space-y-2">
<div className="rounded-md border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-800">
Ajouté au panier
</div>
<Link
href="/panier"
className="block w-full min-h-[44px] rounded-md bg-emerald-600 px-4 py-3 text-center text-sm font-semibold text-white hover:bg-emerald-700"
>
Voir mon panier
</Link>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,56 @@
"use client";
import { useEffect, useState } from "react";
type Day = {
date: string;
availableQty: number;
bookedQty: number;
totalQty: number;
};
export function AvailabilityPreview({ itemId }: { itemId: string }) {
const [calendar, setCalendar] = useState<Day[] | null>(null);
useEffect(() => {
const today = new Date();
today.setUTCHours(0, 0, 0, 0);
const to = new Date(today.getTime() + 30 * 86_400_000);
const fromStr = today.toISOString().slice(0, 10);
const toStr = to.toISOString().slice(0, 10);
fetch(`/api/rentals/items/${itemId}/availability?from=${fromStr}&to=${toStr}`)
.then((r) => (r.ok ? r.json() : null))
.then((j) => {
if (j?.calendar) setCalendar(j.calendar);
})
.catch(() => {});
}, [itemId]);
if (!calendar) {
return <div className="h-16 w-full animate-pulse rounded-md bg-zinc-100" />;
}
return (
<div>
<p className="text-xs text-zinc-500 mb-2">
Disponibilité sur les 30 prochains jours (vert = stock dispo, gris = épuisé) :
</p>
<div className="grid grid-cols-15 gap-0.5 sm:grid-cols-30" style={{ gridTemplateColumns: `repeat(${calendar.length}, minmax(0, 1fr))` }}>
{calendar.map((d) => {
const ratio = d.availableQty / Math.max(1, d.totalQty);
const tone =
d.availableQty === 0 ? "bg-zinc-300" :
ratio < 0.3 ? "bg-amber-300" :
"bg-emerald-400";
return (
<div
key={d.date}
className={`h-4 rounded-sm ${tone}`}
title={`${d.date} : ${d.availableQty}/${d.totalQty} dispo`}
/>
);
})}
</div>
</div>
);
}

View file

@ -0,0 +1,74 @@
"use client";
import { useState } from "react";
type Media = { id: string; type: "PHOTO" | "VIDEO"; s3Url: string };
export function ItemGallery({
media,
fallbackEmoji,
alt,
}: {
media: Media[];
fallbackEmoji: string;
alt: string;
}) {
const [idx, setIdx] = useState(0);
if (media.length === 0) {
return (
<div className="flex aspect-[4/3] w-full items-center justify-center rounded-lg bg-zinc-100 text-7xl text-zinc-300">
{fallbackEmoji}
</div>
);
}
const current = media[idx];
return (
<div className="space-y-2">
<div className="overflow-hidden rounded-lg bg-zinc-100">
{current.type === "VIDEO" ? (
<video
src={current.s3Url}
controls
playsInline
className="aspect-[4/3] w-full object-cover"
/>
) : (
// eslint-disable-next-line @next/next/no-img-element
<img
src={current.s3Url}
alt={alt}
className="aspect-[4/3] w-full object-cover"
/>
)}
</div>
{media.length > 1 ? (
<div className="grid grid-cols-5 gap-1">
{media.map((m, i) => (
<button
key={m.id}
type="button"
onClick={() => setIdx(i)}
className={
"aspect-square overflow-hidden rounded-md border transition " +
(i === idx
? "border-emerald-500 ring-2 ring-emerald-200"
: "border-zinc-200 hover:border-zinc-400 opacity-70 hover:opacity-100")
}
aria-label={`Photo ${i + 1}`}
>
{m.type === "VIDEO" ? (
<div className="flex h-full w-full items-center justify-center bg-zinc-900 text-white"></div>
) : (
// eslint-disable-next-line @next/next/no-img-element
<img src={m.s3Url} alt="" className="h-full w-full object-cover" />
)}
</button>
))}
</div>
) : null}
</div>
);
}

View file

@ -0,0 +1,164 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { requirePluginOr404 } from "@/lib/plugins/guard";
import { getPublicRentalItem } from "@/lib/rentals-public";
import { RENTAL_CATEGORY_LABEL } from "@/lib/rental-category-labels";
import { AddToCart } from "./_components/AddToCart";
import { AvailabilityPreview } from "./_components/AvailabilityPreview";
import { ItemGallery } from "./_components/ItemGallery";
export const dynamic = "force-dynamic";
type PageProps = { params: Promise<{ itemId: string }> };
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { itemId } = await params;
const item = await getPublicRentalItem(itemId);
if (!item) return { title: "Item introuvable", robots: { index: false } };
return {
title: `${item.name} — Location matériel`,
description: item.description ?? `Location de ${item.name} via ${item.provider.name}.`,
};
}
export default async function RentalItemDetailPage({ params }: PageProps) {
await requirePluginOr404("gear-rental");
const { itemId } = await params;
const item = await getPublicRentalItem(itemId);
if (!item) notFound();
const categoryEmoji =
item.category === "SLEEP" ? "💤" :
item.category === "NAVIGATION" ? "🛶" :
item.category === "FISHING" ? "🎣" :
item.category === "COOKING" ? "🍳" : "🦺";
return (
<main className="mx-auto max-w-5xl px-6 py-8">
<Link href="/materiel" className="text-sm text-zinc-600 hover:text-zinc-900">
Tout le matériel
</Link>
<div className="mt-3 grid gap-8 lg:grid-cols-3">
<div className="lg:col-span-2">
<header>
<p className="text-xs uppercase tracking-wider text-zinc-500">
{RENTAL_CATEGORY_LABEL[item.category]}
</p>
<h1 className="mt-1 text-3xl font-semibold text-zinc-900">{item.name}</h1>
<p className="mt-1 text-sm text-zinc-600">
Loué par <strong>{item.provider.name}</strong>
{item.provider.isSystemD ? (
<span className="ml-2 rounded-full bg-emerald-600 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-white">
Fournisseur Karbé
</span>
) : null}
</p>
</header>
<div className="mt-5">
<ItemGallery
media={
item.media.length > 0
? item.media
: item.imageUrl
? [{ id: "legacy", type: "PHOTO", s3Url: item.imageUrl }]
: []
}
alt={item.name}
fallbackEmoji={categoryEmoji}
/>
</div>
{item.description ? (
<section className="mt-6">
<h2 className="text-xl font-semibold text-zinc-900">Description</h2>
<p className="mt-2 whitespace-pre-line text-sm text-zinc-700">{item.description}</p>
</section>
) : null}
<section className="mt-6">
<h2 className="text-xl font-semibold text-zinc-900">Caractéristiques</h2>
<ul className="mt-3 grid grid-cols-2 gap-2 text-sm sm:grid-cols-3">
<li className="rounded-md border border-zinc-200 bg-white px-3 py-2">
<div className="text-[10px] uppercase tracking-wider text-zinc-500">Stock disponible</div>
<div className="font-mono font-semibold text-zinc-900">{item.totalQty} unités</div>
</li>
{Number(item.deposit) > 0 ? (
<li className="rounded-md border border-zinc-200 bg-white px-3 py-2">
<div className="text-[10px] uppercase tracking-wider text-zinc-500">Caution</div>
<div className="font-mono font-semibold text-zinc-900">{Number(item.deposit).toFixed(0)} </div>
</li>
) : null}
{item.withMotor ? (
<li className="rounded-md border border-zinc-200 bg-white px-3 py-2"> Avec moteur</li>
) : null}
{item.fuelIncluded ? (
<li className="rounded-md border border-emerald-200 bg-emerald-50 px-3 py-2 text-emerald-900"> Essence incluse</li>
) : null}
{item.requiresLicense ? (
<li className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-amber-900">🪪 Permis bateau requis</li>
) : null}
</ul>
</section>
<section className="mt-6">
<h2 className="text-xl font-semibold text-zinc-900">Disponibilité</h2>
<div className="mt-3 rounded-lg border border-zinc-200 bg-white p-4">
<AvailabilityPreview itemId={item.id} />
</div>
</section>
</div>
<aside className="space-y-4 rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<div>
<div className="flex items-baseline justify-between">
<span>
<span className="text-3xl font-semibold text-zinc-900">
{Number(item.pricePerDay).toFixed(0)}
</span>
<span className="ml-1 text-sm text-zinc-500">/ jour</span>
</span>
</div>
{item.pricePerWeek ? (
<p className="mt-1 text-xs text-zinc-500">
Forfait semaine : {Number(item.pricePerWeek).toFixed(0)} ( 7 jours)
</p>
) : null}
</div>
<AddToCart
itemId={item.id}
pricePerDay={Number(item.pricePerDay)}
deposit={Number(item.deposit)}
maxQty={item.totalQty}
/>
<div className="border-t border-zinc-100 pt-3">
<h3 className="text-sm font-semibold text-zinc-900">{item.provider.name}</h3>
{item.provider.isSystemD ? (
<p className="mt-1 text-xs text-emerald-700">Fournisseur officiel Karbé (0% commission).</p>
) : null}
{item.provider.description ? (
<p className="mt-2 text-xs text-zinc-600">{item.provider.description}</p>
) : null}
<div className="mt-2 space-y-1 text-xs text-zinc-700">
{item.provider.contactEmail ? (
<p>📧 <a href={`mailto:${item.provider.contactEmail}`} className="underline">{item.provider.contactEmail}</a></p>
) : null}
{item.provider.contactPhone ? (
<p>📞 {item.provider.contactPhone}</p>
) : null}
<p className="text-zinc-500">
Fleuves desservis : {item.provider.rivers.join(", ") || "—"}
</p>
</div>
</div>
</aside>
</div>
</main>
);
}

View file

@ -0,0 +1,100 @@
import Link from "next/link";
import { RentalCategory } from "@/generated/prisma/enums";
import { RENTAL_CATEGORIES, RENTAL_CATEGORY_LABEL } from "@/lib/rental-category-labels";
type Props = {
filters: {
q?: string;
category?: RentalCategory;
providerId?: string;
river?: string;
};
rivers: string[];
providers: { id: string; name: string; isSystemD: boolean }[];
};
export function RentalFilters({ filters, rivers, providers }: Props) {
return (
<form method="get" action="/materiel" className="space-y-3 rounded-lg border border-zinc-200 bg-white p-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-zinc-700">Recherche</span>
<input
type="text"
name="q"
defaultValue={filters.q ?? ""}
placeholder="nom, description…"
className="rounded-md border border-zinc-300 px-3 py-2 text-sm focus:border-emerald-500 focus:outline-none"
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-zinc-700">Fleuve</span>
<select
name="river"
defaultValue={filters.river ?? ""}
className="rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm focus:border-emerald-500 focus:outline-none"
>
<option value="">Tous fleuves</option>
{rivers.map((r) => (
<option key={r} value={r}>{r}</option>
))}
</select>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-zinc-700">Prestataire</span>
<select
name="providerId"
defaultValue={filters.providerId ?? ""}
className="rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm focus:border-emerald-500 focus:outline-none"
>
<option value="">Tous prestataires</option>
{providers.map((p) => (
<option key={p.id} value={p.id}>
{p.name}{p.isSystemD ? " (Karbé)" : ""}
</option>
))}
</select>
</label>
</div>
<fieldset>
<legend className="text-xs uppercase tracking-wider text-zinc-500">Catégorie</legend>
<div className="mt-1 flex flex-wrap gap-1.5">
{RENTAL_CATEGORIES.map((c) => {
const checked = filters.category === c;
return (
<label
key={c}
className={
"flex cursor-pointer items-center gap-1 rounded-full border px-3 py-1 text-sm transition " +
(checked
? "border-emerald-600 bg-emerald-50 text-emerald-900"
: "border-zinc-300 bg-white text-zinc-700 hover:border-zinc-400")
}
>
<input
type="radio"
name="category"
value={c}
defaultChecked={checked}
className="sr-only"
/>
{RENTAL_CATEGORY_LABEL[c]}
</label>
);
})}
</div>
</fieldset>
<div className="flex items-center gap-2">
<button type="submit" className="rounded-md bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700">
Filtrer
</button>
<Link href="/materiel" className="text-sm text-zinc-500 hover:text-zinc-900">
Réinit.
</Link>
</div>
</form>
);
}

View file

@ -0,0 +1,76 @@
import Link from "next/link";
import type { PublicRentalItem } from "@/lib/rentals-public";
import { RENTAL_CATEGORY_LABEL } from "@/lib/rental-category-labels";
export function RentalItemCard({ item }: { item: PublicRentalItem }) {
return (
<Link
href={`/materiel/${item.id}`}
className="group flex flex-col overflow-hidden rounded-lg border border-zinc-200 bg-white shadow-sm transition hover:border-emerald-300 hover:shadow-md"
>
<div className="relative aspect-[4/3] bg-zinc-100">
{item.imageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={item.imageUrl}
alt={item.name}
loading="lazy"
className="h-full w-full object-cover transition group-hover:scale-[1.02]"
/>
) : (
<div className="flex h-full items-center justify-center text-4xl text-zinc-300">
{item.category === "SLEEP" ? "💤" :
item.category === "NAVIGATION" ? "🛶" :
item.category === "FISHING" ? "🎣" :
item.category === "COOKING" ? "🍳" : "🦺"}
</div>
)}
<span className="absolute left-2 top-2 rounded-full bg-white/90 px-2 py-0.5 text-[10px] font-semibold text-zinc-800 ring-1 ring-zinc-200">
{RENTAL_CATEGORY_LABEL[item.category]}
</span>
{item.provider.isSystemD ? (
<span className="absolute right-2 top-2 rounded-full bg-emerald-600 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-white">
Karbé
</span>
) : null}
</div>
<div className="flex flex-1 flex-col p-3">
<h3 className="text-base font-semibold text-zinc-900 group-hover:text-emerald-700">
{item.name}
</h3>
<p className="mt-0.5 text-xs text-zinc-500">{item.provider.name}</p>
<p className="mt-1 line-clamp-2 text-xs text-zinc-600">{item.description ?? ""}</p>
<div className="mt-2 flex flex-wrap items-center gap-1.5 text-[10px]">
{item.withMotor ? (
<span className="rounded-full bg-zinc-100 px-1.5 py-0.5 text-zinc-700"> moteur</span>
) : null}
{item.requiresLicense ? (
<span className="rounded-full bg-amber-50 px-1.5 py-0.5 text-amber-800">🪪 permis</span>
) : null}
{item.fuelIncluded ? (
<span className="rounded-full bg-emerald-50 px-1.5 py-0.5 text-emerald-800"> essence</span>
) : null}
{Number(item.deposit) > 0 ? (
<span className="rounded-full bg-zinc-100 px-1.5 py-0.5 text-zinc-700">
Caution {Number(item.deposit).toFixed(0)}
</span>
) : null}
</div>
<div className="mt-3 flex items-baseline justify-between border-t border-zinc-100 pt-2">
<span>
<span className="text-lg font-semibold text-zinc-900">
{Number(item.pricePerDay).toFixed(0)}
</span>
<span className="ml-1 text-xs text-zinc-500">/ jour</span>
</span>
{item.pricePerWeek ? (
<span className="text-[10px] text-zinc-500">
{Number(item.pricePerWeek).toFixed(0)} / semaine
</span>
) : null}
</div>
</div>
</Link>
);
}

View file

@ -0,0 +1,6 @@
import { requirePluginOr404 } from "@/lib/plugins/guard";
export default async function MaterielLayout({ children }: { children: React.ReactNode }) {
await requirePluginOr404("gear-rental");
return <>{children}</>;
}

123
src/app/materiel/page.tsx Normal file
View file

@ -0,0 +1,123 @@
import type { Metadata } from "next";
import { RentalCategory } from "@/generated/prisma/enums";
import { requirePluginOr404 } from "@/lib/plugins/guard";
import { isRentalCategory } from "@/lib/rental-category-labels";
import {
listPublicProviders,
listPublicRentalItems,
listPublicRivers,
} from "@/lib/rentals-public";
import { RentalFilters } from "./_components/rental-filters";
import { RentalItemCard } from "./_components/rental-item-card";
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "Louer du matériel",
description:
"Hamac, moustiquaire, pirogue, kayak, barque, gilet, réchaud… Toutes les locations de matériel pour réussir votre séjour en carbet guyanais, fournies par l'association System D et des prestataires locaux validés.",
};
type PageProps = {
searchParams: Promise<{
q?: string;
category?: string;
providerId?: string;
river?: string;
}>;
};
export default async function MaterialPage({ searchParams }: PageProps) {
await requirePluginOr404("gear-rental");
const sp = await searchParams;
const filters = {
q: sp.q?.trim() || undefined,
category: sp.category && isRentalCategory(sp.category) ? (sp.category as RentalCategory) : undefined,
providerId: sp.providerId || undefined,
river: sp.river || undefined,
};
const [items, providers, rivers] = await Promise.all([
listPublicRentalItems(filters),
listPublicProviders(),
listPublicRivers(),
]);
return (
<main className="mx-auto max-w-7xl px-6 py-10">
<header className="mb-6">
<h1 className="text-3xl font-semibold text-zinc-900 sm:text-4xl">
Matériel à louer
</h1>
<p className="mt-2 max-w-2xl text-sm text-zinc-600">
Hamac, moustiquaire, pirogue, kayak, barque, réchaud, gilet de sauvetage
Tout le matériel pour réussir votre séjour, mis à disposition par
l&apos;<strong>association System D</strong> ou par des prestataires
locaux validés.
</p>
</header>
<RentalFilters filters={filters} rivers={rivers} providers={providers} />
<section className="mt-6" aria-live="polite">
<p className="mb-3 text-sm text-zinc-600">
{items.length} item{items.length > 1 ? "s" : ""} disponible
{items.length > 1 ? "s" : ""}
</p>
{items.length === 0 ? (
<div className="rounded-md border border-dashed border-zinc-300 px-6 py-12 text-center text-sm text-zinc-500">
Aucun item ne correspond à votre recherche. Essayez d&apos;élargir
les filtres.
</div>
) : (
<ul className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{items.map((item) => (
<li key={item.id}>
<RentalItemCard item={item} />
</li>
))}
</ul>
)}
</section>
{providers.length > 0 ? (
<section className="mt-12 border-t border-zinc-200 pt-8">
<h2 className="text-xl font-semibold text-zinc-900">
Nos prestataires partenaires
</h2>
<p className="mt-1 text-sm text-zinc-600">
{providers.length} prestataire{providers.length > 1 ? "s" : ""} valid
{providers.length > 1 ? "és" : "é"} sur Karbé.
</p>
<ul className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{providers.map((p) => (
<li
key={p.id}
className="rounded-lg border border-zinc-200 bg-white p-4 shadow-sm"
>
<div className="flex items-baseline justify-between gap-2">
<h3 className="text-base font-semibold text-zinc-900">{p.name}</h3>
{p.isSystemD ? (
<span className="rounded-full bg-emerald-600 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-white">
Karbé
</span>
) : null}
</div>
<p className="mt-1 text-xs text-zinc-500">
Fleuves : {p.rivers.join(", ") || "—"} · {p.itemsCount} item
{p.itemsCount > 1 ? "s" : ""}
</p>
{p.description ? (
<p className="mt-2 line-clamp-3 text-xs text-zinc-600">
{p.description}
</p>
) : null}
</li>
))}
</ul>
</section>
) : null}
</main>
);
}

View file

@ -0,0 +1,150 @@
import Link from "next/link";
import { CancelRentalButton } from "@/components/CancelRentalButton";
import { requireAuth } from "@/lib/authorization";
import { requirePluginOr404 } from "@/lib/plugins/guard";
import { prisma } from "@/lib/prisma";
import { RENTAL_CATEGORY_LABEL } from "@/lib/rental-category-labels";
export const dynamic = "force-dynamic";
export const metadata = { title: "Mes locations matériel" };
const STATUS_LABEL: Record<string, string> = {
PENDING: "En attente",
CONFIRMED: "Confirmée",
HANDED_OVER: "Remis",
RETURNED: "Retourné",
CANCELLED: "Annulée",
};
const PAYMENT_LABEL: Record<string, string> = {
PENDING: "Paiement en attente",
AUTHORIZED: "Paiement autorisé",
SUCCEEDED: "Paiement reçu",
FAILED: "Paiement échoué",
REFUNDED: "Remboursé",
};
type SearchParams = Promise<{ payment?: string; ids?: string; ok?: string }>;
export default async function MyRentalsPage({ searchParams }: { searchParams: SearchParams }) {
await requirePluginOr404("gear-rental");
const session = await requireAuth();
const sp = await searchParams;
const rentals = await prisma.rentalBooking.findMany({
where: { tenantId: session.user.id },
orderBy: [{ startDate: "desc" }],
include: {
provider: { select: { id: true, name: true, isSystemD: true, contactPhone: true, contactEmail: true } },
lines: { include: { item: { select: { id: true, name: true, category: true, imageUrl: true } } } },
booking: { select: { id: true, carbet: { select: { slug: true, title: true } } } },
},
});
const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "long", year: "numeric" });
const showSuccess = sp.payment === "success" || sp.ok;
return (
<main className="mx-auto w-full max-w-3xl flex-1 px-6 py-10">
<header>
<h1 className="text-3xl font-semibold text-zinc-900">Mes locations matériel</h1>
<p className="mt-2 text-sm text-zinc-600">
Récap des hamacs, kayaks, pirogues et autres équipements loués pour vos séjours.
</p>
</header>
{showSuccess ? (
<div className="mt-4 rounded-md border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900">
Votre commande de matériel a bien é enregistrée. Vous recevrez un email de confirmation.
</div>
) : null}
{rentals.length === 0 ? (
<p className="mt-10 rounded-md border border-dashed border-zinc-300 bg-zinc-50 p-6 text-center text-sm text-zinc-600">
Vous n&apos;avez pas encore loué de matériel.{" "}
<Link href="/materiel" className="text-emerald-700 hover:underline">
Découvrir le matériel disponible
</Link>
.
</p>
) : (
<ul className="mt-8 space-y-5">
{rentals.map((rb) => (
<li key={rb.id} className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<div>
<h2 className="text-lg font-semibold text-zinc-900">{rb.provider.name}</h2>
{rb.booking?.carbet ? (
<p className="text-xs text-zinc-500">
Pour le séjour{" "}
<Link href={`/carbets/${rb.booking.carbet.slug}`} className="hover:underline">
{rb.booking.carbet.title}
</Link>
</p>
) : (
<p className="text-xs text-zinc-500">Location indépendante</p>
)}
</div>
<div className="flex flex-col items-end gap-1 text-xs">
<span className="rounded-full bg-sky-100 px-2 py-0.5 font-semibold uppercase tracking-wider text-sky-800 ring-1 ring-inset ring-sky-300">
{STATUS_LABEL[rb.status] ?? rb.status}
</span>
<span className="rounded-full bg-zinc-100 px-2 py-0.5 font-semibold uppercase tracking-wider text-zinc-700 ring-1 ring-inset ring-zinc-300">
{PAYMENT_LABEL[rb.paymentStatus] ?? rb.paymentStatus}
</span>
</div>
</div>
<p className="mt-2 text-sm text-zinc-700">
Du {dateFmt.format(rb.startDate)} au {dateFmt.format(rb.endDate)}
</p>
<ul className="mt-3 divide-y divide-zinc-100 text-sm">
{rb.lines.map((line) => (
<li key={line.id} className="flex items-center justify-between py-2">
<span>
<span className="font-medium text-zinc-900">{line.qty}×</span>{" "}
<Link href={`/materiel/${line.item.id}`} className="hover:underline">
{line.item.name}
</Link>
<span className="ml-2 text-xs text-zinc-500">
{RENTAL_CATEGORY_LABEL[line.item.category]}
</span>
</span>
<span className="font-mono text-xs text-zinc-700">
{Number(line.lineTotal).toFixed(2)}
</span>
</li>
))}
</ul>
<div className="mt-3 flex items-baseline justify-between border-t border-zinc-100 pt-2 text-sm">
<span className="text-zinc-600">Total</span>
<span className="font-mono font-semibold text-zinc-900">
{Number(rb.amount).toFixed(2)} {rb.currency}
</span>
</div>
{(rb.provider.contactPhone || rb.provider.contactEmail) && rb.status !== "CANCELLED" ? (
<p className="mt-2 text-xs text-zinc-500">
Contact prestataire :{" "}
{rb.provider.contactPhone ? <span>📞 {rb.provider.contactPhone} </span> : null}
{rb.provider.contactEmail ? <span> {rb.provider.contactEmail}</span> : null}
</p>
) : null}
{(rb.status === "PENDING" || rb.status === "CONFIRMED") ? (
<div className="mt-3 flex justify-end">
<CancelRentalButton rentalBookingId={rb.id} label="Annuler ma location" />
</div>
) : null}
</li>
))}
</ul>
)}
</main>
);
}

View file

@ -0,0 +1,256 @@
"use client";
import { useMemo, useState, useTransition } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useCart } from "@/components/RentalCartProvider";
import { diffDays } from "@/lib/rental-cart";
type ItemSnapshot = {
id: string;
name: string;
category: string;
imageUrl: string | null;
pricePerDay: string;
deposit: string;
totalQty: number;
provider: { id: string; name: string; isSystemD: boolean };
};
type Line = {
idx: number;
entry: { itemId: string; qty: number; startDate: string; endDate: string };
item: ItemSnapshot;
};
export function CartReview({ lines }: { lines: Line[] }) {
const router = useRouter();
const { removeEntry, updateEntry, clear } = useCart();
const [busy, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
// Groupe par prestataire
const groups = useMemo(() => {
const map = new Map<string, { providerName: string; isSystemD: boolean; lines: Line[]; subtotal: number; deposit: number }>();
for (const l of lines) {
const nights = Math.max(1, diffDays(l.entry.startDate, l.entry.endDate));
const lineSub = nights * l.entry.qty * Number(l.item.pricePerDay);
const lineDeposit = l.entry.qty * Number(l.item.deposit);
const existing = map.get(l.item.provider.id);
if (existing) {
existing.lines.push(l);
existing.subtotal += lineSub;
existing.deposit += lineDeposit;
} else {
map.set(l.item.provider.id, {
providerName: l.item.provider.name,
isSystemD: l.item.provider.isSystemD,
lines: [l],
subtotal: lineSub,
deposit: lineDeposit,
});
}
}
return Array.from(map.values());
}, [lines]);
const grandTotal = groups.reduce((acc, g) => acc + g.subtotal, 0);
const grandDeposit = groups.reduce((acc, g) => acc + g.deposit, 0);
function checkout() {
setError(null);
startTransition(async () => {
const res = await fetch("/api/rentals/checkout", { method: "POST" });
const json = await res.json().catch(() => ({}));
if (!res.ok) {
setError(json?.error || `Erreur ${res.status}`);
return;
}
if (json.checkoutUrl) {
window.location.assign(json.checkoutUrl);
return;
}
if (json.rentalBookingIds?.length) {
clear();
router.push(`/mes-locations?ok=${json.rentalBookingIds[0]}`);
return;
}
router.push("/mes-locations");
});
}
return (
<div className="space-y-6">
{groups.map((g) => (
<section key={g.providerName} className="rounded-lg border border-zinc-200 bg-white shadow-sm">
<header className="border-b border-zinc-100 px-4 py-3">
<h2 className="text-base font-semibold text-zinc-900">
{g.providerName}
{g.isSystemD ? (
<span className="ml-2 rounded-full bg-emerald-600 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-white">
Karbé
</span>
) : null}
</h2>
</header>
<ul className="divide-y divide-zinc-100">
{g.lines.map((l) => (
<CartLineItem
key={l.idx}
line={l}
onRemove={() => removeEntry(l.idx)}
onChangeQty={(qty) => updateEntry(l.idx, { qty })}
onChangeDates={(startDate, endDate) => updateEntry(l.idx, { startDate, endDate })}
disabled={busy}
/>
))}
</ul>
<footer className="flex items-center justify-between border-t border-zinc-100 bg-zinc-50 px-4 py-2 text-sm">
<span className="text-zinc-600">Sous-total prestataire</span>
<span className="font-mono font-semibold text-zinc-900">{g.subtotal.toFixed(2)} </span>
</footer>
</section>
))}
<aside
className="sticky z-10 rounded-lg border border-zinc-200 bg-white p-4 shadow-md"
style={{
// iOS Safari : tient compte de la safe-area (home indicator).
// Fallback à 0.75rem (= bottom-3) sur les navigateurs sans env().
bottom: "max(0.75rem, env(safe-area-inset-bottom, 0.75rem))",
}}
>
<dl className="space-y-1 text-sm">
<div className="flex justify-between">
<dt>Total location</dt>
<dd className="font-mono font-semibold">{grandTotal.toFixed(2)} </dd>
</div>
{grandDeposit > 0 ? (
<div className="flex justify-between text-xs text-zinc-500">
<dt>+ Caution récupérable</dt>
<dd className="font-mono">{grandDeposit.toFixed(2)} </dd>
</div>
) : null}
<div className="flex justify-between border-t border-zinc-100 pt-2 text-base font-semibold text-zinc-900">
<dt>À régler</dt>
<dd className="font-mono">{(grandTotal + grandDeposit).toFixed(2)} </dd>
</div>
</dl>
{error ? (
<div className="mt-2 rounded border border-rose-200 bg-rose-50 px-3 py-1.5 text-xs text-rose-700">
{error}
</div>
) : null}
<div className="mt-3 flex flex-wrap items-center justify-between gap-2">
<button
type="button"
onClick={clear}
disabled={busy}
className="text-xs text-zinc-500 hover:text-zinc-900"
>
Vider le panier
</button>
<button
type="button"
onClick={checkout}
disabled={busy || lines.length === 0}
className="rounded-md bg-emerald-600 px-5 py-2 text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
>
{busy ? "Envoi…" : "Valider et payer"}
</button>
</div>
<p className="mt-2 text-center text-[11px] text-zinc-500">
Vous devez être <Link href="/connexion?next=/panier" className="underline">connecté</Link> pour finaliser.
</p>
</aside>
</div>
);
}
function CartLineItem({
line,
onRemove,
onChangeQty,
onChangeDates,
disabled,
}: {
line: Line;
onRemove: () => void;
onChangeQty: (qty: number) => void;
onChangeDates: (start: string, end: string) => void;
disabled?: boolean;
}) {
const nights = Math.max(1, diffDays(line.entry.startDate, line.entry.endDate));
const lineTotal = nights * line.entry.qty * Number(line.item.pricePerDay);
return (
<li className="flex flex-wrap items-center gap-3 px-4 py-3">
<div className="hidden h-14 w-20 shrink-0 overflow-hidden rounded bg-zinc-100 sm:block">
{line.item.imageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={line.item.imageUrl} alt={line.item.name} className="h-full w-full object-cover" />
) : (
<div className="flex h-full items-center justify-center text-2xl text-zinc-300">
{line.item.category === "SLEEP" ? "💤" :
line.item.category === "NAVIGATION" ? "🛶" :
line.item.category === "FISHING" ? "🎣" :
line.item.category === "COOKING" ? "🍳" : "🦺"}
</div>
)}
</div>
<div className="flex-1 min-w-0">
<Link href={`/materiel/${line.item.id}`} className="font-medium text-zinc-900 hover:underline">
{line.item.name}
</Link>
<div className="mt-1 grid grid-cols-2 gap-1 text-xs sm:grid-cols-4">
<label className="block">
<span className="text-zinc-500">Du</span>
<input
type="date"
value={line.entry.startDate}
onChange={(e) => onChangeDates(e.target.value, line.entry.endDate)}
disabled={disabled}
className="block w-full rounded border border-zinc-200 px-1.5 py-0.5"
/>
</label>
<label className="block">
<span className="text-zinc-500">Au</span>
<input
type="date"
value={line.entry.endDate}
onChange={(e) => onChangeDates(line.entry.startDate, e.target.value)}
disabled={disabled}
className="block w-full rounded border border-zinc-200 px-1.5 py-0.5"
/>
</label>
<label className="block">
<span className="text-zinc-500">Qté</span>
<input
type="number"
min={1}
max={line.item.totalQty}
value={line.entry.qty}
onChange={(e) => onChangeQty(Math.max(1, Math.min(line.item.totalQty, Number(e.target.value) || 1)))}
disabled={disabled}
className="block w-full rounded border border-zinc-200 px-1.5 py-0.5"
/>
</label>
<div className="flex flex-col text-right">
<span className="text-zinc-500">{nights} j × {Number(line.item.pricePerDay).toFixed(0)} </span>
<span className="font-mono font-semibold text-zinc-900">{lineTotal.toFixed(2)} </span>
</div>
</div>
</div>
<button
type="button"
onClick={onRemove}
disabled={disabled}
className="text-xs text-rose-700 hover:text-rose-900 disabled:opacity-50"
>
Retirer
</button>
</li>
);
}

83
src/app/panier/page.tsx Normal file
View file

@ -0,0 +1,83 @@
import Link from "next/link";
import { requirePluginOr404 } from "@/lib/plugins/guard";
import { prisma } from "@/lib/prisma";
import { readCartFromCookies } from "@/lib/rental-cart-server";
import { CartReview } from "./_components/CartReview";
export const dynamic = "force-dynamic";
export const metadata = { title: "Mon panier matériel" };
export default async function CartPage() {
await requirePluginOr404("gear-rental");
const cart = await readCartFromCookies();
// Charge les items du panier en bulk pour rendu
const ids = Array.from(new Set(cart.items.map((e) => e.itemId)));
const items = ids.length
? await prisma.rentalItem.findMany({
where: { id: { in: ids } },
include: {
provider: { select: { id: true, name: true, isSystemD: true, commissionPct: true } },
},
})
: [];
const itemById = new Map(items.map((i) => [i.id, i]));
const lines = cart.items
.map((entry, idx) => {
const item = itemById.get(entry.itemId);
if (!item) return null;
return {
idx,
entry,
item: {
id: item.id,
name: item.name,
category: item.category,
imageUrl: item.imageUrl,
pricePerDay: item.pricePerDay.toString(),
deposit: item.deposit.toString(),
totalQty: item.totalQty,
provider: {
id: item.provider.id,
name: item.provider.name,
isSystemD: item.provider.isSystemD,
},
},
};
})
.filter((l): l is NonNullable<typeof l> => l !== null);
return (
<main className="mx-auto max-w-4xl px-6 py-10">
<header className="mb-6">
<Link href="/materiel" className="text-xs text-zinc-500 hover:text-zinc-900">
Continuer mes achats
</Link>
<h1 className="mt-1 text-3xl font-semibold text-zinc-900">Mon panier matériel</h1>
<p className="mt-1 text-sm text-zinc-600">
{lines.length === 0
? "Votre panier est vide."
: `${lines.length} ligne${lines.length > 1 ? "s" : ""} de location.`}
</p>
</header>
{lines.length === 0 ? (
<div className="rounded-lg border border-dashed border-zinc-300 px-6 py-12 text-center">
<p className="text-sm text-zinc-600">Pas encore d&apos;item dans votre panier.</p>
<Link
href="/materiel"
className="mt-3 inline-block rounded-md bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700"
>
Découvrir le matériel
</Link>
</div>
) : (
<CartReview lines={lines} />
)}
</main>
);
}

View file

@ -1,8 +1,10 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { ContentPageRenderer } from "@/components/ContentPageRenderer";
import { getContentPage } from "@/lib/content-pages";
import { getLocale } from "@/lib/i18n/server";
import { isPluginEnabled } from "@/lib/plugins/server";
import { ContentPageRenderer } from "@/components/ContentPageRenderer";
export const dynamic = "force-dynamic";
@ -15,5 +17,28 @@ export default async function CEPage() {
if (!(await isPluginEnabled("content-pages"))) notFound();
const page = await getContentPage("pour-comites-entreprise", await getLocale());
if (!page) notFound();
return <ContentPageRenderer page={page} />;
const ceEnabled = await isPluginEnabled("ce-management");
return (
<>
<ContentPageRenderer page={page} />
{ceEnabled ? (
<section className="mx-auto my-8 max-w-3xl rounded-lg border border-emerald-200 bg-emerald-50/60 px-6 py-6 text-center">
<h2 className="text-lg font-semibold text-emerald-900">
Vous êtes un Comité d&apos;Entreprise ?
</h2>
<p className="mt-1 text-sm text-emerald-900">
Créez votre espace CE sur Karbé pour proposer vos carbets à vos membres et au public
touriste, et activer la location de matériel.
</p>
<Link
href="/inscription"
className="mt-3 inline-block rounded-md bg-emerald-600 px-5 py-2 text-sm font-semibold text-white hover:bg-emerald-700"
>
Créer mon espace CE
</Link>
</section>
) : null}
</>
);
}

View file

@ -34,6 +34,16 @@ export default async function ReservationPage({ params }: PageProps) {
include: {
carbet: { select: { title: true, slug: true, river: true } },
tenant: { select: { id: true, email: true } },
rentalBookings: {
select: {
id: true,
status: true,
amount: true,
currency: true,
provider: { select: { name: true } },
lines: { select: { qty: true, item: { select: { id: true, name: true } } } },
},
},
},
});
if (!booking) notFound();
@ -97,6 +107,34 @@ export default async function ReservationPage({ params }: PageProps) {
</div>
</section>
{booking.rentalBookings.length > 0 ? (
<section className="mt-6 rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="text-lg font-semibold text-zinc-900">Matériel associé</h2>
<ul className="mt-3 space-y-3 text-sm">
{booking.rentalBookings.map((rb) => (
<li key={rb.id} className="rounded-md border border-zinc-100 bg-zinc-50/60 p-3">
<div className="flex justify-between">
<span className="font-medium text-zinc-900">{rb.provider.name}</span>
<span className="font-mono text-xs text-zinc-700">
{Number(rb.amount).toFixed(2)} {rb.currency}
</span>
</div>
<ul className="mt-1 text-xs text-zinc-600">
{rb.lines.map((l, i) => (
<li key={i}>
{l.qty}× <Link href={`/materiel/${l.item.id}`} className="hover:underline">{l.item.name}</Link>
</li>
))}
</ul>
</li>
))}
</ul>
<Link href="/mes-locations" className="mt-3 inline-block text-xs text-emerald-700 hover:underline">
Voir toutes mes locations
</Link>
</section>
) : null}
<div className="mt-6 flex items-center justify-between text-sm">
<Link href={`/carbets/${booking.carbet.slug}`} className="text-zinc-700 hover:text-zinc-900 hover:underline">
Retour au carbet

View file

@ -31,6 +31,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
firstName: true,
lastName: true,
role: true,
organizationId: true,
isActive: true,
passwordHash: true,
},
@ -50,6 +51,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
email: user.email,
name: `${user.firstName} ${user.lastName}`.trim(),
role: user.role,
organizationId: user.organizationId,
};
},
}),
@ -59,12 +61,16 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
if (user?.role) {
token.role = user.role;
}
if (user && "organizationId" in user) {
token.organizationId = (user as { organizationId?: string | null }).organizationId ?? null;
}
return token;
},
async session({ session, token }) {
if (session.user) {
session.user.id = token.sub ?? "";
session.user.role = token.role;
session.user.organizationId = token.organizationId ?? null;
}
return session;
},

View file

@ -0,0 +1,100 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
type Props = {
rentalBookingId: string;
/** Label adapté au contexte d'appel : « Annuler ma location » côté tenant, etc. */
label?: string;
/** Affichage compact dans une grille d'actions (pas de margin auto). */
compact?: boolean;
};
export function CancelRentalButton({
rentalBookingId,
label = "Annuler",
compact = false,
}: Props) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const [confirmOpen, setConfirmOpen] = useState(false);
const [reason, setReason] = useState("");
function submit() {
setError(null);
startTransition(async () => {
const res = await fetch(`/api/rentals/${rentalBookingId}/cancel`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reason }),
});
const json = await res.json().catch(() => ({}));
if (!res.ok) {
setError(json?.error || `Erreur ${res.status}`);
return;
}
setConfirmOpen(false);
router.refresh();
});
}
if (!confirmOpen) {
return (
<button
type="button"
onClick={() => setConfirmOpen(true)}
className={
"rounded-md border border-rose-200 px-3 py-1.5 text-sm text-rose-700 hover:bg-rose-50 " +
(compact ? "" : "")
}
>
{label}
</button>
);
}
return (
<div className="rounded-md border border-rose-200 bg-rose-50/50 p-3 text-sm">
<p className="font-semibold text-rose-900">Confirmer l&apos;annulation</p>
<p className="mt-1 text-xs text-rose-800">
Le remboursement est calculé selon la politique : 100 % si annulation à plus de 7 jours,
50 % entre 1 et 7 jours, caution seulement à moins de 24h.
</p>
<label className="mt-2 block">
<span className="text-xs text-rose-900">Motif (optionnel)</span>
<textarea
value={reason}
onChange={(e) => setReason(e.target.value)}
maxLength={500}
rows={2}
className="mt-1 w-full rounded border border-rose-200 bg-white px-2 py-1 text-xs"
placeholder="Ex. changement de date, indisponibilité…"
/>
</label>
{error ? <p className="mt-2 text-xs text-rose-700">{error}</p> : null}
<div className="mt-2 flex justify-end gap-2">
<button
type="button"
onClick={() => {
setConfirmOpen(false);
setError(null);
}}
disabled={pending}
className="rounded-md border border-zinc-300 bg-white px-3 py-1 text-xs text-zinc-700 hover:bg-zinc-50"
>
Garder la résa
</button>
<button
type="button"
onClick={submit}
disabled={pending}
className="rounded-md bg-rose-600 px-3 py-1 text-xs font-semibold text-white hover:bg-rose-700 disabled:opacity-60"
>
{pending ? "Annulation…" : "Confirmer"}
</button>
</div>
</div>
);
}

View file

@ -0,0 +1,22 @@
"use client";
import Link from "next/link";
import { useCart } from "./RentalCartProvider";
export function CartBadge() {
const { totalItems } = useCart();
if (totalItems === 0) return null;
return (
<Link
href="/panier"
className="relative inline text-zinc-700 hover:text-zinc-900"
aria-label={`Panier (${totalItems} item)`}
>
🛒
<span className="absolute -right-2 -top-2 inline-flex h-4 min-w-[1rem] items-center justify-center rounded-full bg-emerald-600 px-1 text-[10px] font-semibold text-white">
{totalItems}
</span>
</Link>
);
}

View file

@ -28,11 +28,53 @@ export type MediaItem = {
sortOrder: number;
};
/**
* Le composant gère deux périmètres : carbet (par défaut) et item de location.
* Les endpoints sont alors `/api/uploads/{rental-}{presign,finalize}`,
* `/api/{rental-}media/{id}` et `/api/{rental-}media/reorder` ; la clé de
* scope dans les payloads passe de `carbetId` à `itemId`.
*/
export type UploaderScope =
| { kind: "carbet"; carbetId: string }
| { kind: "rental-item"; itemId: string };
type Props = {
carbetId: string;
scope?: UploaderScope;
/** @deprecated — passer `scope={{kind:"carbet", carbetId}}` à la place. */
carbetId?: string;
initialMedia: MediaItem[];
};
type Endpoints = {
presign: string;
finalize: string;
reorder: string;
remove: (mediaId: string) => string;
idKey: "carbetId" | "itemId";
idValue: string;
};
function endpointsFor(scope: UploaderScope): Endpoints {
if (scope.kind === "carbet") {
return {
presign: "/api/uploads/presign",
finalize: "/api/uploads/finalize",
reorder: "/api/media/reorder",
remove: (id) => `/api/media/${id}`,
idKey: "carbetId",
idValue: scope.carbetId,
};
}
return {
presign: "/api/uploads/rental-presign",
finalize: "/api/uploads/rental-finalize",
reorder: "/api/rental-media/reorder",
remove: (id) => `/api/rental-media/${id}`,
idKey: "itemId",
idValue: scope.itemId,
};
}
type UploadEntry = {
tempId: string;
name: string;
@ -45,7 +87,11 @@ type UploadEntry = {
const MAX_PARALLEL = 3;
export function MediaUploader({ carbetId, initialMedia }: Props) {
export function MediaUploader({ scope, carbetId, initialMedia }: Props) {
const endpoints = useMemo(
() => endpointsFor(scope ?? { kind: "carbet", carbetId: carbetId ?? "" }),
[scope, carbetId],
);
const [items, setItems] = useState<MediaItem[]>(
[...initialMedia].sort((a, b) => a.sortOrder - b.sortOrder),
);
@ -66,13 +112,13 @@ export function MediaUploader({ carbetId, initialMedia }: Props) {
const reorderOnServer = useCallback(
async (orderedIds: string[]) => {
await fetch("/api/media/reorder", {
await fetch(endpoints.reorder, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ carbetId, orderedIds }),
body: JSON.stringify({ [endpoints.idKey]: endpoints.idValue, orderedIds }),
}).catch(() => {});
},
[carbetId],
[endpoints],
);
function onDragEnd(e: DragEndEvent) {
@ -103,9 +149,9 @@ export function MediaUploader({ carbetId, initialMedia }: Props) {
const removeItem = useCallback(async (id: string) => {
if (!confirm("Supprimer ce média ?")) return;
const res = await fetch(`/api/media/${id}`, { method: "DELETE" });
const res = await fetch(endpoints.remove(id), { method: "DELETE" });
if (res.ok) setItems((prev) => prev.filter((p) => p.id !== id));
}, []);
}, [endpoints]);
const processFile = useCallback(async function processFile(file: File): Promise<void> {
const tempId = crypto.randomUUID();
@ -114,10 +160,14 @@ export function MediaUploader({ carbetId, initialMedia }: Props) {
{ tempId, name: file.name, sizeBytes: file.size, mime: file.type, progress: 0, done: false },
]);
try {
const presignRes = await fetch("/api/uploads/presign", {
const presignRes = await fetch(endpoints.presign, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ carbetId, mime: file.type, sizeBytes: file.size }),
body: JSON.stringify({
[endpoints.idKey]: endpoints.idValue,
mime: file.type,
sizeBytes: file.size,
}),
});
const presign = await presignRes.json();
if (!presignRes.ok) throw new Error(presign?.error || "presign refusé");
@ -138,11 +188,11 @@ export function MediaUploader({ carbetId, initialMedia }: Props) {
xhr.send(file);
});
const finalizeRes = await fetch("/api/uploads/finalize", {
const finalizeRes = await fetch(endpoints.finalize, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
carbetId,
[endpoints.idKey]: endpoints.idValue,
s3Key: presign.s3Key,
s3Url: presign.publicUrl,
mime: file.type,
@ -160,7 +210,7 @@ export function MediaUploader({ carbetId, initialMedia }: Props) {
const msg = e instanceof Error ? e.message : String(e);
setUploads((u) => u.map((x) => (x.tempId === tempId ? { ...x, error: msg } : x)));
}
}, [carbetId]);
}, [endpoints]);
const popQueueRef = useRef<() => void>(() => {});
const popQueue = useCallback(() => {

View file

@ -0,0 +1,224 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { signOut } from "next-auth/react";
import Link from "next/link";
import { usePathname } from "next/navigation";
type LinkItem = { href: string; label: string };
type Props = {
isAuthenticated: boolean;
isOwner: boolean;
isRentalProvider: boolean;
isCeManager: boolean;
isAdmin: boolean;
rentalEnabled: boolean;
ceEnabled: boolean;
};
/**
* Bouton hamburger visible uniquement sur mobile (sm:hidden).
* Ouvre un drawer qui rassemble tous les liens de navigation, car en
* mobile les liens du SiteHeader sont masqués pour rester sur 1 ligne.
*/
export function MobileMenuButton({
isAuthenticated,
isOwner,
isRentalProvider,
isCeManager,
isAdmin,
rentalEnabled,
ceEnabled,
}: Props) {
const [open, setOpen] = useState(false);
const pathname = usePathname();
// Ferme le menu si on change de page — pathname comparé à la valeur précédente
// dans un effect avec setState, façon "useRef + condition" pour éviter le
// warning react-hooks/set-state-in-effect (setState dans un effect sans
// dépendance externe = anti-pattern).
const lastPathnameRef = useRef(pathname);
useEffect(() => {
if (lastPathnameRef.current !== pathname) {
lastPathnameRef.current = pathname;
// closure ref → reflète bien la dernière valeur ; setOpen est stable
// (renvoyé par useState) donc OK dans deps.
setOpen(false);
}
}, [pathname]);
// Empêche le scroll sous-jacent quand ouvert
useEffect(() => {
if (!open) return;
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = prev;
};
}, [open]);
const publicLinks: LinkItem[] = [
{ href: "/decouvrir", label: "Au fil de l'eau" },
{ href: "/carbets", label: "Catalogue" },
...(rentalEnabled ? [{ href: "/materiel", label: "Matériel" }] : []),
];
const userLinks: LinkItem[] = isAuthenticated
? [
{ href: "/mes-favoris", label: "Favoris" },
{ href: "/mes-reservations", label: "Mes réservations" },
...(rentalEnabled ? [{ href: "/mes-locations", label: "Mes locations" }] : []),
{ href: "/mon-compte", label: "Mon compte" },
]
: [];
const proLinks: LinkItem[] = isAuthenticated
? [
...(isOwner ? [{ href: "/espace-hote", label: "Espace hôte" }] : []),
...(isRentalProvider && rentalEnabled
? [{ href: "/espace-prestataire", label: "Espace prestataire" }]
: []),
...(isCeManager && ceEnabled ? [{ href: "/espace-ce", label: "Espace CE" }] : []),
...(isAdmin ? [{ href: "/admin", label: "Admin" }] : []),
]
: [];
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
aria-label="Ouvrir le menu"
aria-expanded={open}
className="inline-flex h-9 w-9 items-center justify-center rounded-md text-zinc-700 hover:bg-zinc-100 sm:hidden"
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor">
<path d="M3 5h14v2H3V5zm0 4h14v2H3V9zm0 4h14v2H3v-2z" />
</svg>
</button>
{open ? (
<div className="fixed inset-0 z-50 sm:hidden">
<button
type="button"
aria-label="Fermer le menu"
onClick={() => setOpen(false)}
className="absolute inset-0 bg-zinc-900/40"
/>
<div className="absolute right-0 top-0 flex h-full w-72 max-w-[85vw] flex-col overflow-y-auto bg-white shadow-2xl">
<div className="flex items-center justify-between border-b border-zinc-200 px-4 py-3">
<span className="text-base font-semibold text-zinc-900">Menu</span>
<button
type="button"
onClick={() => setOpen(false)}
aria-label="Fermer"
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-zinc-500 hover:bg-zinc-100"
>
<svg width="16" height="16" viewBox="0 0 20 20" fill="currentColor">
<path d="M4.3 4.3l1.4-1.4L10 7.2l4.3-4.3 1.4 1.4L11.4 8.6l4.3 4.3-1.4 1.4L10 10l-4.3 4.3-1.4-1.4 4.3-4.3z" />
</svg>
</button>
</div>
<nav className="flex-1 px-2 py-3 text-sm">
<MenuSection label="Découvrir">
{publicLinks.map((l) => (
<MenuLink key={l.href} href={l.href} pathname={pathname}>
{l.label}
</MenuLink>
))}
</MenuSection>
{userLinks.length > 0 ? (
<MenuSection label="Mon compte">
{userLinks.map((l) => (
<MenuLink key={l.href} href={l.href} pathname={pathname}>
{l.label}
</MenuLink>
))}
</MenuSection>
) : null}
{proLinks.length > 0 ? (
<MenuSection label="Espaces pro">
{proLinks.map((l) => (
<MenuLink key={l.href} href={l.href} pathname={pathname}>
{l.label}
</MenuLink>
))}
</MenuSection>
) : null}
</nav>
<div className="border-t border-zinc-200 p-3">
{isAuthenticated ? (
<button
type="button"
onClick={() => signOut({ callbackUrl: "/" })}
className="w-full rounded-md border border-zinc-300 px-4 py-2 text-center text-sm font-semibold text-zinc-700 hover:bg-zinc-50"
>
Se déconnecter
</button>
) : (
<div className="flex flex-col gap-2">
<Link
href="/connexion"
className="rounded-md border border-zinc-300 px-4 py-2 text-center text-sm font-semibold text-zinc-900 hover:bg-zinc-50"
>
Connexion
</Link>
<Link
href="/inscription"
className="rounded-md bg-zinc-900 px-4 py-2 text-center text-sm font-semibold text-white hover:bg-zinc-800"
>
Créer un compte
</Link>
</div>
)}
</div>
</div>
</div>
) : null}
</>
);
}
function MenuSection({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<section className="mb-2">
<h3 className="px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-zinc-500">
{label}
</h3>
<ul className="space-y-0.5">{children}</ul>
</section>
);
}
function MenuLink({
href,
pathname,
children,
}: {
href: string;
pathname: string;
children: React.ReactNode;
}) {
const active = pathname === href || (href !== "/" && pathname.startsWith(href));
return (
<li>
<Link
href={href}
className={
"block rounded-md px-3 py-2 " +
(active
? "bg-emerald-50 font-semibold text-emerald-900"
: "text-zinc-700 hover:bg-zinc-100")
}
>
{children}
</Link>
</li>
);
}

View file

@ -0,0 +1,110 @@
"use client";
import {
createContext,
useCallback,
useContext,
useMemo,
useState,
type ReactNode,
} from "react";
import {
CART_COOKIE,
EMPTY_CART,
parseCart,
serializeCart,
type Cart,
type CartEntry,
} from "@/lib/rental-cart";
type CartContextValue = {
cart: Cart;
addEntry: (entry: CartEntry) => void;
removeEntry: (index: number) => void;
updateEntry: (index: number, patch: Partial<CartEntry>) => void;
clear: () => void;
totalItems: number;
};
const Ctx = createContext<CartContextValue | null>(null);
function readCookieClient(): Cart {
if (typeof document === "undefined") return EMPTY_CART;
const match = document.cookie.split(/;\s*/).find((c) => c.startsWith(`${CART_COOKIE}=`));
if (!match) return EMPTY_CART;
const value = decodeURIComponent(match.slice(CART_COOKIE.length + 1));
return parseCart(value);
}
function writeCookieClient(cart: Cart): void {
if (typeof document === "undefined") return;
document.cookie = `${CART_COOKIE}=${encodeURIComponent(serializeCart(cart))}; path=/; max-age=${60 * 60 * 24 * 30}; SameSite=Lax`;
}
export function RentalCartProvider({ children, initial }: { children: ReactNode; initial?: Cart }) {
// Initial vient du serveur (cookie lu côté serveur). Sur le client on relit le
// cookie une seule fois via lazy initializer pour rester cohérent si un autre
// onglet a modifié le panier entre le render serveur et l'hydration.
const [cart, setCart] = useState<Cart>(() => {
if (typeof document === "undefined") return initial ?? EMPTY_CART;
const fromCookie = readCookieClient();
return fromCookie.items.length > 0 ? fromCookie : initial ?? EMPTY_CART;
});
const persist = useCallback((next: Cart) => {
setCart(next);
writeCookieClient(next);
}, []);
const addEntry = useCallback(
(entry: CartEntry) => {
const next = { ...cart, items: [...cart.items, entry] };
persist(next);
},
[cart, persist],
);
const removeEntry = useCallback(
(index: number) => {
const next = { ...cart, items: cart.items.filter((_, i) => i !== index) };
persist(next);
},
[cart, persist],
);
const updateEntry = useCallback(
(index: number, patch: Partial<CartEntry>) => {
const next = {
...cart,
items: cart.items.map((e, i) => (i === index ? { ...e, ...patch } : e)),
};
persist(next);
},
[cart, persist],
);
const clear = useCallback(() => {
persist({ v: 1, items: [] });
}, [persist]);
const value = useMemo<CartContextValue>(
() => ({
cart,
addEntry,
removeEntry,
updateEntry,
clear,
totalItems: cart.items.reduce((acc, e) => acc + e.qty, 0),
}),
[cart, addEntry, removeEntry, updateEntry, clear],
);
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
}
export function useCart(): CartContextValue {
const ctx = useContext(Ctx);
if (!ctx) throw new Error("useCart must be used inside <RentalCartProvider>");
return ctx;
}

View file

@ -7,7 +7,10 @@ import Link from "next/link";
import { auth } from "@/auth";
import { UserRole } from "@/generated/prisma/enums";
import { isPluginEnabled } from "@/lib/plugins/server";
import { CartBadge } from "./CartBadge";
import { MobileMenuButton } from "./MobileMenuButton";
import { SignOutButton } from "./SignOutButton";
export async function SiteHeader() {
@ -15,6 +18,12 @@ export async function SiteHeader() {
const u = session?.user;
const isAdmin = u?.role === UserRole.ADMIN;
const isOwner = u?.role === UserRole.OWNER || isAdmin;
const isRentalProvider = u?.role === UserRole.RENTAL_PROVIDER || isAdmin;
const isCeManager = u?.role === UserRole.CE_MANAGER || isAdmin;
const [rentalEnabled, ceEnabled] = await Promise.all([
isPluginEnabled("gear-rental"),
isPluginEnabled("ce-management"),
]);
return (
<header className="sticky top-0 z-30 border-b border-zinc-200 bg-white/85 backdrop-blur supports-[backdrop-filter]:bg-white/70">
@ -33,12 +42,16 @@ export async function SiteHeader() {
<Link href="/carbets" className="hover:text-zinc-900">
Catalogue
</Link>
<Link href="/comment-ca-marche" className="hover:text-zinc-900">
Comment ça marche
</Link>
{rentalEnabled ? (
<Link href="/materiel" className="hover:text-zinc-900">
Matériel
</Link>
) : null}
</nav>
<div className="flex items-center gap-3 text-sm">
{rentalEnabled ? <CartBadge /> : null}
{/* Desktop-only links (sm+) */}
{u ? (
<>
<Link href="/mes-favoris" className="hidden text-zinc-700 hover:text-zinc-900 sm:inline">
@ -47,6 +60,11 @@ export async function SiteHeader() {
<Link href="/mes-reservations" className="hidden text-zinc-700 hover:text-zinc-900 sm:inline">
Mes réservations
</Link>
{rentalEnabled ? (
<Link href="/mes-locations" className="hidden text-zinc-700 hover:text-zinc-900 sm:inline">
Mes locations
</Link>
) : null}
<Link href="/mon-compte" className="hidden text-zinc-700 hover:text-zinc-900 sm:inline">
Mon compte
</Link>
@ -55,6 +73,16 @@ export async function SiteHeader() {
Espace hôte
</Link>
) : null}
{isRentalProvider && rentalEnabled ? (
<Link href="/espace-prestataire" className="hidden text-zinc-700 hover:text-zinc-900 sm:inline">
Espace prestataire
</Link>
) : null}
{isCeManager && ceEnabled ? (
<Link href="/espace-ce" className="hidden text-zinc-700 hover:text-zinc-900 sm:inline">
Espace CE
</Link>
) : null}
{isAdmin ? (
<Link href="/admin" className="hidden rounded-md bg-zinc-900 px-2.5 py-1 text-xs font-semibold text-white hover:bg-zinc-800 sm:inline-block">
Admin
@ -63,21 +91,33 @@ export async function SiteHeader() {
<span className="hidden max-w-[14ch] truncate text-xs text-zinc-500 md:inline" title={u.email ?? ""}>
{u.name || u.email}
</span>
<SignOutButton />
<span className="hidden sm:inline">
<SignOutButton />
</span>
</>
) : (
<>
<Link href="/connexion" className="text-zinc-700 hover:text-zinc-900">
<Link href="/connexion" className="hidden text-zinc-700 hover:text-zinc-900 sm:inline">
Connexion
</Link>
<Link
href="/inscription"
className="rounded-md bg-zinc-900 px-3 py-1 text-xs font-semibold text-white hover:bg-zinc-800"
className="hidden rounded-md bg-zinc-900 px-3 py-1 text-xs font-semibold text-white hover:bg-zinc-800 sm:inline-block"
>
Créer un compte
</Link>
</>
)}
{/* Mobile-only burger menu */}
<MobileMenuButton
isAuthenticated={Boolean(u)}
isOwner={Boolean(isOwner)}
isRentalProvider={Boolean(isRentalProvider)}
isCeManager={Boolean(isCeManager)}
isAdmin={isAdmin}
rentalEnabled={rentalEnabled}
ceEnabled={ceEnabled}
/>
</div>
</div>
</header>

View file

@ -108,7 +108,10 @@ const ICONS = {
const GROUPS: NavGroup[] = [
{
label: "Vue d'ensemble",
items: [{ href: "/admin", label: "Dashboard", icon: ICONS.dashboard }],
items: [
{ href: "/admin", label: "Dashboard", icon: ICONS.dashboard },
{ href: "/admin/analytics", label: "Analytics", icon: ICONS.dashboard },
],
},
{
label: "Catalogue",
@ -125,6 +128,7 @@ const GROUPS: NavGroup[] = [
items: [
{ href: "/admin/bookings", label: "Réservations", icon: ICONS.bookings },
{ href: "/admin/rentals", label: "Locations matériel", icon: ICONS.bookings },
{ href: "/admin/payouts", label: "Reversements", icon: ICONS.bookings },
{ href: "/admin/reviews", label: "Avis & modération", icon: ICONS.reviews },
],
},

View file

@ -0,0 +1,113 @@
/**
* Bar chart SVG simple pas de lib externe. Stack carbetRevenue + rentalRevenue.
* Affiche les 12 derniers mois en barres verticales.
*/
type Point = {
month: string;
carbetRevenue: number;
rentalRevenue: number;
total: number;
};
const MONTH_LABEL = ["jan", "fév", "mar", "avr", "mai", "jun", "jul", "aoû", "sep", "oct", "nov", "déc"];
function shortMonth(ym: string): string {
const [, m] = ym.split("-");
return MONTH_LABEL[parseInt(m, 10) - 1] ?? ym;
}
function fmtEur(n: number): string {
return n.toLocaleString("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 });
}
export function MonthlyRevenueChart({ data }: { data: Point[] }) {
const max = Math.max(1, ...data.map((d) => d.total));
const width = Math.max(360, data.length * 40);
const height = 180;
const padBottom = 24;
const padTop = 8;
const barWidth = width / data.length - 8;
const usableHeight = height - padTop - padBottom;
return (
<div className="overflow-x-auto">
<svg
viewBox={`0 0 ${width} ${height}`}
className="w-full max-w-full"
role="img"
aria-label="Chiffre d'affaires mensuel"
>
{/* Y-axis grid */}
{[0, 0.25, 0.5, 0.75, 1].map((p) => {
const y = padTop + usableHeight * (1 - p);
return (
<g key={p}>
<line x1={36} x2={width} y1={y} y2={y} stroke="#e4e4e7" strokeWidth={1} strokeDasharray="2 4" />
<text x={4} y={y + 3} fontSize={9} fill="#71717a">
{fmtEur(max * p)}
</text>
</g>
);
})}
{data.map((d, i) => {
const x = 40 + i * (width / data.length) + 4;
const carbetH = (d.carbetRevenue / max) * usableHeight;
const rentalH = (d.rentalRevenue / max) * usableHeight;
const baseY = padTop + usableHeight;
return (
<g key={d.month}>
{/* Carbet revenue (bas) */}
{carbetH > 0 ? (
<rect
x={x}
y={baseY - carbetH}
width={barWidth}
height={carbetH}
fill="#059669"
rx={2}
>
<title>
Carbet {d.month} : {fmtEur(d.carbetRevenue)}
</title>
</rect>
) : null}
{/* Rental revenue (top de la stack) */}
{rentalH > 0 ? (
<rect
x={x}
y={baseY - carbetH - rentalH}
width={barWidth}
height={rentalH}
fill="#f59e0b"
rx={2}
>
<title>
Matériel {d.month} : {fmtEur(d.rentalRevenue)}
</title>
</rect>
) : null}
<text
x={x + barWidth / 2}
y={height - 6}
fontSize={10}
textAnchor="middle"
fill="#71717a"
>
{shortMonth(d.month)}
</text>
</g>
);
})}
</svg>
<div className="mt-2 flex flex-wrap items-center gap-3 text-[11px] text-zinc-600">
<span className="flex items-center gap-1">
<span className="inline-block h-2.5 w-2.5 rounded-sm bg-emerald-600" /> Carbet
</span>
<span className="flex items-center gap-1">
<span className="inline-block h-2.5 w-2.5 rounded-sm bg-amber-500" /> Matériel rental
</span>
</div>
</div>
);
}

View file

@ -111,10 +111,24 @@ export async function getCarbetForEdit(id: string) {
owner: { select: { id: true, firstName: true, lastName: true, email: true } },
pirogueProvider: { select: { id: true, name: true } },
media: { orderBy: { sortOrder: "asc" } },
organizations: {
orderBy: { addedAt: "asc" },
include: {
organization: { select: { id: true, name: true, slug: true, approved: true } },
},
},
_count: { select: { bookings: true, reviews: true } },
},
});
}
/** Liste les orgs disponibles pour link sur un carbet — toutes orgs (approuvées et pending). */
export async function listOrganizationsForLink() {
return prisma.organization.findMany({
orderBy: [{ approved: "desc" }, { name: "asc" }],
select: { id: true, name: true, slug: true, approved: true },
});
}
// Options enum déplacées dans `./carbet-options.ts` pour être importables
// depuis les composants client (ce fichier-ci est server-only).

View file

@ -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 };
}

View file

@ -80,6 +80,10 @@ export async function getRentalItemForAdmin(id: string) {
where: { id },
include: {
provider: { select: { id: true, name: true, isSystemD: true } },
media: {
orderBy: { sortOrder: "asc" },
select: { id: true, type: true, s3Url: true, s3Key: true, sortOrder: true },
},
},
});
}

218
src/lib/analytics.ts Normal file
View file

@ -0,0 +1,218 @@
import "server-only";
import {
BookingStatus,
RentalBookingStatus,
} from "@/generated/prisma/enums";
import { prisma } from "@/lib/prisma";
const MONTH_MS = 30 * 24 * 60 * 60 * 1000;
export type MonthlyRevenuePoint = {
/** "YYYY-MM" */
month: string;
carbetRevenue: number;
rentalRevenue: number;
total: number;
};
/**
* CA mensuel sur les 12 derniers mois calendaires.
* Scope optionnel par organisation CE filtre via Carbet.organizations (memberships)
* et RentalProvider.organizationId.
*/
export async function getMonthlyRevenueSeries(opts: {
organizationId?: string;
monthsBack?: number;
} = {}): Promise<MonthlyRevenuePoint[]> {
const monthsBack = opts.monthsBack ?? 12;
const since = new Date();
since.setMonth(since.getMonth() - monthsBack);
since.setDate(1);
since.setHours(0, 0, 0, 0);
const carbetWhere = {
status: BookingStatus.CONFIRMED,
createdAt: { gte: since },
...(opts.organizationId
? { carbet: { organizations: { some: { organizationId: opts.organizationId } } } }
: {}),
};
const rentalWhere = {
status: { in: [RentalBookingStatus.CONFIRMED, RentalBookingStatus.HANDED_OVER, RentalBookingStatus.RETURNED] },
createdAt: { gte: since },
...(opts.organizationId ? { provider: { organizationId: opts.organizationId } } : {}),
};
const [bookings, rentals] = await Promise.all([
prisma.booking.findMany({
where: carbetWhere,
select: { amount: true, createdAt: true },
}),
prisma.rentalBooking.findMany({
where: rentalWhere,
select: { amount: true, createdAt: true },
}),
]);
const map = new Map<string, MonthlyRevenuePoint>();
for (let i = 0; i < monthsBack; i++) {
const d = new Date();
d.setMonth(d.getMonth() - (monthsBack - 1 - i));
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
map.set(key, { month: key, carbetRevenue: 0, rentalRevenue: 0, total: 0 });
}
for (const b of bookings) {
const key = `${b.createdAt.getFullYear()}-${String(b.createdAt.getMonth() + 1).padStart(2, "0")}`;
const p = map.get(key);
if (p) p.carbetRevenue += Number(b.amount);
}
for (const r of rentals) {
const key = `${r.createdAt.getFullYear()}-${String(r.createdAt.getMonth() + 1).padStart(2, "0")}`;
const p = map.get(key);
if (p) p.rentalRevenue += Number(r.amount);
}
for (const p of map.values()) p.total = p.carbetRevenue + p.rentalRevenue;
return Array.from(map.values());
}
export type CarbetOccupancy = {
carbetId: string;
title: string;
slug: string;
totalNights: number;
bookedNights: number;
occupancyPct: number;
};
/**
* Taux d'occupation des carbets sur la fenêtre `monthsBack` (par défaut 3).
* Calcule (nuits réservées CONFIRMED fenêtre) / (totalNights de la fenêtre) en %.
*/
export async function getCarbetsOccupancy(opts: {
organizationId?: string;
monthsBack?: number;
} = {}): Promise<CarbetOccupancy[]> {
const monthsBack = opts.monthsBack ?? 3;
const since = new Date(Date.now() - monthsBack * MONTH_MS);
const now = new Date();
const totalNights = Math.max(1, Math.floor((now.getTime() - since.getTime()) / 86_400_000));
const carbets = await prisma.carbet.findMany({
where: {
status: "PUBLISHED",
...(opts.organizationId
? { organizations: { some: { organizationId: opts.organizationId } } }
: {}),
},
select: {
id: true,
title: true,
slug: true,
bookings: {
where: {
status: BookingStatus.CONFIRMED,
startDate: { lt: now },
endDate: { gt: since },
},
select: { startDate: true, endDate: true },
},
},
});
return carbets
.map((c) => {
const booked = c.bookings.reduce((sum, b) => {
const start = Math.max(b.startDate.getTime(), since.getTime());
const end = Math.min(b.endDate.getTime(), now.getTime());
return sum + Math.max(0, Math.floor((end - start) / 86_400_000));
}, 0);
const occupancyPct = Math.round((booked / totalNights) * 1000) / 10;
return {
carbetId: c.id,
title: c.title,
slug: c.slug,
totalNights,
bookedNights: booked,
occupancyPct,
};
})
.sort((a, b) => b.occupancyPct - a.occupancyPct);
}
export type AdminGlobalKpis = {
usersTotal: number;
usersByRole: Record<string, number>;
carbetsPublished: number;
bookings30d: number;
rentals30d: number;
revenue30d: number;
topCarbets: { carbetId: string; title: string; slug: string; revenue: number }[];
topProviders: { providerId: string; name: string; revenue: number }[];
};
export async function getAdminGlobalKpis(): Promise<AdminGlobalKpis> {
const since = new Date(Date.now() - 30 * 86_400_000);
const [usersByRoleRows, carbetsPublished, bookings30d, rentals30d] = await Promise.all([
prisma.user.groupBy({
by: ["role"],
_count: { _all: true },
}),
prisma.carbet.count({ where: { status: "PUBLISHED" } }),
prisma.booking.findMany({
where: { status: BookingStatus.CONFIRMED, createdAt: { gte: since } },
select: { amount: true, carbetId: true, carbet: { select: { title: true, slug: true } } },
}),
prisma.rentalBooking.findMany({
where: {
status: { in: [RentalBookingStatus.CONFIRMED, RentalBookingStatus.HANDED_OVER, RentalBookingStatus.RETURNED] },
createdAt: { gte: since },
},
select: { amount: true, providerId: true, provider: { select: { name: true } } },
}),
]);
const usersByRole: Record<string, number> = {};
let usersTotal = 0;
for (const row of usersByRoleRows) {
usersByRole[row.role] = row._count._all;
usersTotal += row._count._all;
}
const carbetAgg = new Map<string, { title: string; slug: string; revenue: number }>();
for (const b of bookings30d) {
const v = carbetAgg.get(b.carbetId) ?? { title: b.carbet.title, slug: b.carbet.slug, revenue: 0 };
v.revenue += Number(b.amount);
carbetAgg.set(b.carbetId, v);
}
const topCarbets = Array.from(carbetAgg.entries())
.map(([carbetId, v]) => ({ carbetId, ...v }))
.sort((a, b) => b.revenue - a.revenue)
.slice(0, 5);
const providerAgg = new Map<string, { name: string; revenue: number }>();
for (const r of rentals30d) {
const v = providerAgg.get(r.providerId) ?? { name: r.provider.name, revenue: 0 };
v.revenue += Number(r.amount);
providerAgg.set(r.providerId, v);
}
const topProviders = Array.from(providerAgg.entries())
.map(([providerId, v]) => ({ providerId, ...v }))
.sort((a, b) => b.revenue - a.revenue)
.slice(0, 5);
const bookingsRevenue = bookings30d.reduce((s, b) => s + Number(b.amount), 0);
const rentalsRevenue = rentals30d.reduce((s, r) => s + Number(r.amount), 0);
return {
usersTotal,
usersByRole,
carbetsPublished,
bookings30d: bookings30d.length,
rentals30d: rentals30d.length,
revenue30d: bookingsRevenue + rentalsRevenue,
topCarbets,
topProviders,
};
}

View file

@ -3,19 +3,44 @@ import type { Session } from "next-auth";
import { requireRole } from "@/lib/authorization";
import { UserRole } from "@/generated/prisma/enums";
const MANAGER_ROLES: UserRole[] = [UserRole.OWNER, UserRole.ADMIN];
/**
* Espace hôte ET espace CE (les deux dashboards) : accessible à OWNER, CE_MANAGER, ADMIN.
* Chacun ne voit que ses propres carbets (own ou via membership). La page liste filtre
* par session.user.id / session.user.organizationId.
*/
const MANAGER_ROLES: UserRole[] = [
UserRole.OWNER,
UserRole.CE_MANAGER,
UserRole.ADMIN,
];
// Owner area (espace hôte) — accessible to carbet owners and admins.
export async function requireOwnerSession(): Promise<Session> {
return requireRole(MANAGER_ROLES);
}
// A user can manage a given carbet if they own it, or if they are an admin.
/**
* Vrai si :
* - ADMIN
* - OWNER + session.user.id === carbetOwnerId
* - CE_MANAGER + son organizationId est dans `linkedOrgIds`
*
* Les callers DOIVENT charger `Carbet.organizations.map(m => m.organizationId)` quand le rôle
* peut être CE_MANAGER. Pour un caller historique qui n'a que l'ownerId, le CE_MANAGER ne
* pourra pas gérer le carbet comportement sûr par défaut.
*/
export function canManageCarbet(
session: Session,
carbetOwnerId: string,
linkedOrgIds: string[] = [],
): boolean {
return (
session.user.role === UserRole.ADMIN || session.user.id === carbetOwnerId
);
if (session.user.role === UserRole.ADMIN) return true;
if (session.user.id === carbetOwnerId) return true;
if (
session.user.role === UserRole.CE_MANAGER &&
session.user.organizationId &&
linkedOrgIds.includes(session.user.organizationId)
) {
return true;
}
return false;
}

View file

@ -42,6 +42,8 @@ export type PublicCarbetDetail = {
longitude: string;
ownerId: string;
ownerFirstName: string;
/** Comités d'Entreprise qui co-gèrent ce carbet (vide si hôte individuel). */
organizations: { id: string; name: string; slug: string }[];
media: PublicCarbetMedia[];
amenities: { key: string; label: string }[];
reviewStats: CarbetReviewStats;
@ -99,6 +101,12 @@ export const getPublicCarbet = cache(
amenities: {
select: { amenity: { select: { key: true, label: true } } },
},
organizations: {
where: { organization: { approved: true } },
select: {
organization: { select: { id: true, name: true, slug: true } },
},
},
},
});
@ -146,6 +154,11 @@ export const getPublicCarbet = cache(
longitude: carbet.longitude.toString(),
ownerId: carbet.ownerId,
ownerFirstName: carbet.owner.firstName,
organizations: carbet.organizations.map((m) => ({
id: m.organization.id,
name: m.organization.name,
slug: m.organization.slug,
})),
media: carbet.media.map((m) => ({
id: m.id,
type: m.type,

92
src/lib/ce-access.ts Normal file
View file

@ -0,0 +1,92 @@
import "server-only";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { UserRole } from "@/generated/prisma/enums";
import { prisma } from "@/lib/prisma";
/**
* Garde-fou commun pour /espace-ce/* : redirige vers /connexion si pas de session,
* vers / si le rôle n'est pas CE_MANAGER ni ADMIN.
*/
export async function requireCeManagerSession() {
const session = await auth();
if (!session?.user?.id) {
redirect("/connexion?next=/espace-ce");
}
const role = session.user.role;
if (role !== UserRole.CE_MANAGER && role !== UserRole.ADMIN) {
redirect("/");
}
return session;
}
/**
* Récupère l'Organization de l'utilisateur connecté (via User.organizationId).
* - CE_MANAGER son org (toujours rattaché)
* - ADMIN soit l'org ciblée par `organizationId`, soit null pour forcer le choix
*/
export async function getCurrentCeOrganization(opts: { organizationId?: string } = {}) {
const session = await auth();
if (!session?.user?.id) return null;
const role = session.user.role;
if (role === UserRole.ADMIN && opts.organizationId) {
return prisma.organization.findUnique({ where: { id: opts.organizationId } });
}
if (role === UserRole.ADMIN && !opts.organizationId) {
return null;
}
// CE_MANAGER : retourne son org via User.organizationId
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { organization: true },
});
return user?.organization ?? null;
}
/**
* Un CE_MANAGER peut-il gérer ce carbet ?
* - vrai s'il en est l'owner direct (`Carbet.ownerId == userId`)
* - OU s'il est membre d'une org liée au carbet via OrganizationCarbetMembership
* - ADMIN passe toujours.
*/
export async function canManageCarbetForCe(
userId: string,
role: string | undefined,
carbetId: string,
): Promise<boolean> {
if (role === UserRole.ADMIN) return true;
if (role !== UserRole.CE_MANAGER) return false;
const [carbet, user] = await Promise.all([
prisma.carbet.findUnique({
where: { id: carbetId },
select: {
ownerId: true,
organizations: { select: { organizationId: true } },
},
}),
prisma.user.findUnique({
where: { id: userId },
select: { organizationId: true },
}),
]);
if (!carbet || !user?.organizationId) return false;
if (carbet.ownerId === userId) return true;
return carbet.organizations.some((m) => m.organizationId === user.organizationId);
}
/**
* Garantit que l'org du user est `approved=true`. Sinon redirige vers le dashboard
* /espace-ce qui affiche une bannière « En attente de validation ».
* Utiliser sur les pages qui doivent publier du contenu (créer carbet/item).
*/
export async function requireApprovedOrg() {
const org = await getCurrentCeOrganization();
if (!org || !org.approved) {
redirect("/espace-ce?pending=1");
}
return org;
}

90
src/lib/ce-dashboard.ts Normal file
View file

@ -0,0 +1,90 @@
import "server-only";
import {
BookingStatus,
RentalBookingStatus,
} from "@/generated/prisma/enums";
import { prisma } from "@/lib/prisma";
/**
* KPIs agrégés à l'échelle d'une organisation CE.
* - carbets : nombre de carbets co-gérés via OrganizationCarbetMembership
* - rentalItems : items des providers liés à l'org
* - bookings30d : bookings confirmées sur les carbets de l'org (30 derniers jours)
* - rentalBookings30d : RentalBooking confirmées sur les providers de l'org
* - revenue30d : somme des amounts (booking + rental) sur 30j
*/
export async function getCeOrgKpis(organizationId: string) {
const since = new Date(Date.now() - 30 * 86_400_000);
const [carbetsCount, providers, bookings30d, rentalBookings30d] = await Promise.all([
prisma.organizationCarbetMembership.count({ where: { organizationId } }),
prisma.rentalProvider.findMany({
where: { organizationId },
select: {
id: true,
approved: true,
active: true,
_count: { select: { items: true } },
},
}),
prisma.booking.findMany({
where: {
status: BookingStatus.CONFIRMED,
createdAt: { gte: since },
carbet: { organizations: { some: { organizationId } } },
},
select: { amount: true, currency: true },
}),
prisma.rentalBooking.findMany({
where: {
status: RentalBookingStatus.CONFIRMED,
createdAt: { gte: since },
provider: { organizationId },
},
select: { amount: true, currency: true },
}),
]);
const itemsCount = providers.reduce((s, p) => s + p._count.items, 0);
const revenue30d = [
...bookings30d.map((b) => Number(b.amount)),
...rentalBookings30d.map((r) => Number(r.amount)),
].reduce((s, n) => s + n, 0);
return {
carbetsCount,
providersCount: providers.length,
rentalItemsCount: itemsCount,
rentalProviderApproved: providers.every((p) => p.approved),
bookings30dCount: bookings30d.length,
rentalBookings30dCount: rentalBookings30d.length,
revenue30d,
};
}
/**
* Liste les carbets co-gérés par une org (joinés via membership).
*/
export async function listCeCarbets(organizationId: string) {
const memberships = await prisma.organizationCarbetMembership.findMany({
where: { organizationId },
orderBy: { addedAt: "desc" },
select: {
carbet: {
select: {
id: true,
slug: true,
title: true,
river: true,
status: true,
capacity: true,
nightlyPrice: true,
ownerId: true,
owner: { select: { firstName: true, lastName: true } },
},
},
},
});
return memberships.map((m) => m.carbet);
}

81
src/lib/ce-invites.ts Normal file
View file

@ -0,0 +1,81 @@
import "server-only";
import crypto from "node:crypto";
import { prisma } from "@/lib/prisma";
const INVITE_TTL_MS = 14 * 24 * 60 * 60 * 1000; // 14 jours
/** Hash sha256 d'un token plain → utilisé comme PK pour ne jamais persister le plain. */
export function hashToken(token: string): string {
return crypto.createHash("sha256").update(token).digest("hex");
}
/**
* Vrai si une invitation est encore consommable : pas marquée `usedAt`
* et pas encore expirée. Helper extrait pour testabilité.
*/
export function isInviteValid(
row: { expiresAt: Date; usedAt: Date | null },
now: Date = new Date(),
): boolean {
if (row.usedAt) return false;
if (row.expiresAt < now) return false;
return true;
}
export async function createOrgInviteToken(opts: {
organizationId: string;
createdByUserId: string;
email?: string | null;
ttlMs?: number;
}): Promise<string> {
const token = crypto.randomBytes(24).toString("base64url");
const tokenHash = hashToken(token);
const expiresAt = new Date(Date.now() + (opts.ttlMs ?? INVITE_TTL_MS));
await prisma.orgInviteToken.create({
data: {
tokenHash,
organizationId: opts.organizationId,
createdByUserId: opts.createdByUserId,
email: opts.email ?? null,
expiresAt,
},
});
return token;
}
export async function listOrgInviteTokens(organizationId: string) {
return prisma.orgInviteToken.findMany({
where: { organizationId },
orderBy: { createdAt: "desc" },
take: 50,
});
}
/** Renvoie l'invitation si elle existe, non expirée et non consommée. */
export async function getOrgInviteByToken(plainToken: string) {
const tokenHash = hashToken(plainToken);
const row = await prisma.orgInviteToken.findUnique({
where: { tokenHash },
include: {
organization: { select: { id: true, name: true, slug: true, approved: true } },
},
});
if (!row) return null;
if (!isInviteValid(row)) return null;
return row;
}
/** Marque l'invitation comme consommée. À appeler dans la transaction de signup. */
export async function markOrgInviteConsumed(plainToken: string): Promise<void> {
const tokenHash = hashToken(plainToken);
await prisma.orgInviteToken.update({
where: { tokenHash },
data: { usedAt: new Date() },
});
}
export async function revokeOrgInviteToken(tokenHash: string): Promise<void> {
await prisma.orgInviteToken.delete({ where: { tokenHash } }).catch(() => {});
}

18
src/lib/cron-auth.ts Normal file
View file

@ -0,0 +1,18 @@
import "server-only";
/**
* Auth Bearer pour les endpoints /api/cron/*. Le token est partagé entre le
* serveur et le cron caller externe (Hermes, cron host, etc.).
*
* Renvoie true si l'en-tête Authorization correspond exactement à
* `Bearer ${process.env.CRON_TOKEN}` (timing-safe via le comparateur natif
* acceptable car le token n'est pas dérivable de la requête).
*/
export function isAuthorizedCronRequest(req: Request): boolean {
const expected = (process.env.CRON_TOKEN ?? "").trim();
if (!expected) return false;
const header = req.headers.get("authorization") ?? "";
if (!header.startsWith("Bearer ")) return false;
const token = header.slice("Bearer ".length).trim();
return token === expected;
}

View file

@ -186,6 +186,89 @@ export async function sendBookingConfirmed(
});
}
export async function sendNewCeRequest(
orgName: string,
managerEmail: string,
): Promise<void> {
const adminEmail = process.env.ADMIN_NOTIFICATION_EMAIL ?? "contact@karbe.cosmolan.fr";
await sendEmail({
to: adminEmail,
subject: `Nouvelle demande CE — ${orgName}`,
html: wrap(
"Demande de Comité d'Entreprise à valider",
`<p>Une organisation vient de s'inscrire en tant que Comité d'Entreprise.</p>
<ul>
<li>Nom : <strong>${orgName}</strong></li>
<li>Email du manager : ${managerEmail}</li>
</ul>
<p><a href="${SITE_URL}/admin/organizations?status=pending" style="display:inline-block;background:#18181b;color:white;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">Valider sur l'admin Karbé</a></p>
<p style="font-size:12px;color:#71717a;">Le CE_MANAGER peut accéder à son dashboard mais ne peut rien publier tant que <code>Organization.approved=false</code>.</p>`,
),
});
}
export async function sendCeInviteEmail(
to: string,
orgName: string,
inviteUrl: string,
inviterName?: string | null,
): Promise<void> {
const intro = inviterName
? `<strong>${inviterName}</strong> vous invite à rejoindre`
: "Vous êtes invité à rejoindre";
await sendEmail({
to,
subject: `Invitation à rejoindre « ${orgName} » sur Karbé`,
html: wrap(
`Invitation Karbé — ${orgName}`,
`<p>${intro} le Comité d'Entreprise <strong>${orgName}</strong> sur Karbé.</p>
<p>Cliquez sur le bouton ci-dessous pour créer votre compte CE_MEMBER et accéder aux carbets et matériel de votre CE :</p>
<p><a href="${inviteUrl}" style="display:inline-block;background:#16a34a;color:white;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">Rejoindre ${orgName}</a></p>
<p style="font-size:12px;color:#71717a;">Lien valable 14 jours. Si vous n'êtes pas le destinataire attendu, ignorez cet email.</p>
<p style="font-size:11px;color:#a1a1aa;word-break:break-all;">Lien direct : ${inviteUrl}</p>`,
),
text: `${intro.replace(/<[^>]+>/g, "")} le CE ${orgName} sur Karbé : ${inviteUrl}`,
});
}
export async function sendCeApproved(
to: string,
firstName: string,
orgName: string,
): Promise<void> {
await sendEmail({
to,
subject: `Votre CE « ${orgName} » est validé sur Karbé`,
html: wrap(
"Organisation validée",
`<p>Bonjour ${firstName},</p>
<p>Votre Comité d'Entreprise <strong>${orgName}</strong> vient d'être validé. Vous pouvez désormais publier vos carbets et activer la location de matériel pour vos membres et le public touriste.</p>
<p><a href="${SITE_URL}/espace-ce" style="display:inline-block;background:#16a34a;color:white;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">Accéder à mon espace CE</a></p>`,
),
});
}
export async function sendNewRentalProviderRequest(
providerName: string,
userEmail: string,
): Promise<void> {
const adminEmail = process.env.ADMIN_NOTIFICATION_EMAIL ?? "contact@karbe.cosmolan.fr";
await sendEmail({
to: adminEmail,
subject: `Nouvelle demande prestataire matériel — ${providerName}`,
html: wrap(
"Demande de prestataire à valider",
`<p>Une demande d'inscription en tant que prestataire de location matériel vient d'arriver.</p>
<ul>
<li>Nom : <strong>${providerName}</strong></li>
<li>Email contact : ${userEmail}</li>
</ul>
<p><a href="${SITE_URL}/admin/rental-providers" style="display:inline-block;background:#18181b;color:white;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">Valider sur l'admin Karbé</a></p>
<p style="font-size:12px;color:#71717a;">Le prestataire reste en attente jusqu'à validation. Ses items ne sont pas publiés tant que <code>approved=false</code>.</p>`,
),
});
}
export async function sendPasswordReset(
to: string,
resetUrl: string,
@ -203,6 +286,222 @@ export async function sendPasswordReset(
});
}
type RentalLineSummary = { qty: number; itemName: string };
function renderLines(lines: RentalLineSummary[]): string {
return lines.map((l) => `<li>${l.qty}× ${l.itemName}</li>`).join("");
}
export async function sendRentalRequestedTenant(
to: string,
firstName: string,
rentalBookingId: string,
providerName: string,
startDate: Date,
endDate: Date,
amount: string,
currency: string,
lines: RentalLineSummary[],
): Promise<void> {
const fmt = (d: Date) =>
new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "long", year: "numeric" }).format(d);
await sendEmail({
to,
subject: `Demande de location matériel — ${providerName}`,
html: wrap(
"Votre demande de location est enregistrée",
`<p>Bonjour ${firstName},</p>
<p>Votre demande de location auprès de <strong>${providerName}</strong> est bien enregistrée :</p>
<ul>
<li>Du ${fmt(startDate)} au ${fmt(endDate)}</li>
<li>Montant : ${Number(amount).toFixed(2)} ${currency}</li>
</ul>
<p><strong>Matériel demandé :</strong></p>
<ul>${renderLines(lines)}</ul>
<p>Vous recevrez un nouvel email dès que le paiement sera validé et le prestataire confirmera la préparation du matériel.</p>
<p><a href="${SITE_URL}/mes-locations" style="display:inline-block;background:#18181b;color:white;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">Mes locations</a></p>
<p style="font-size:11px;color:#71717a;">Référence : ${rentalBookingId}</p>`,
),
});
}
export async function sendRentalRequestedProvider(
to: string,
providerName: string,
rentalBookingId: string,
tenantName: string,
startDate: Date,
endDate: Date,
lines: RentalLineSummary[],
): Promise<void> {
const fmt = (d: Date) =>
new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "long", year: "numeric" }).format(d);
await sendEmail({
to,
subject: `Nouvelle demande de location — ${tenantName}`,
html: wrap(
"Nouvelle demande à préparer",
`<p>Bonjour ${providerName},</p>
<p><strong>${tenantName}</strong> vient de réserver du matériel :</p>
<ul>
<li>Du ${fmt(startDate)} au ${fmt(endDate)}</li>
</ul>
<p><strong>Matériel :</strong></p>
<ul>${renderLines(lines)}</ul>
<p>Préparez le matériel pour la remise. Vous recevrez une confirmation paiement une fois le règlement validé.</p>
<p><a href="${SITE_URL}/espace-prestataire/reservations" style="display:inline-block;background:#18181b;color:white;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">Mes réservations</a></p>
<p style="font-size:11px;color:#71717a;">Référence : ${rentalBookingId}</p>`,
),
});
}
export async function sendRentalCancelled(
to: string,
firstName: string,
rentalBookingId: string,
providerName: string,
refundAmount: string,
currency: string,
policyLabel: string,
cancelledBy: "tenant" | "provider" | "admin",
): Promise<void> {
const actor =
cancelledBy === "tenant"
? "Vous avez annulé"
: cancelledBy === "provider"
? `${providerName} a annulé`
: "L'équipe Karbé a annulé";
await sendEmail({
to,
subject: `Location annulée — ${providerName}`,
html: wrap(
"Location annulée",
`<p>Bonjour ${firstName},</p>
<p>${actor} votre location auprès de <strong>${providerName}</strong>.</p>
<p><strong>Politique appliquée :</strong> ${policyLabel}</p>
<p><strong>Remboursement :</strong> ${Number(refundAmount).toFixed(2)} ${currency}</p>
<p>Si un paiement avait é reçu, le remboursement est traité par Stripe sous 3-5 jours ouvrés.</p>
<p><a href="${SITE_URL}/mes-locations" style="display:inline-block;background:#18181b;color:white;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">Mes locations</a></p>
<p style="font-size:11px;color:#71717a;">Référence : ${rentalBookingId}</p>`,
),
});
}
export async function sendRentalConfirmed(
to: string,
firstName: string,
rentalBookingId: string,
providerName: string,
startDate: Date,
endDate: Date,
): Promise<void> {
const fmt = (d: Date) =>
new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "long", year: "numeric" }).format(d);
await sendEmail({
to,
subject: `Location confirmée — ${providerName}`,
html: wrap(
"Votre location est confirmée",
`<p>Bonjour ${firstName},</p>
<p>Le paiement de votre location auprès de <strong>${providerName}</strong> du ${fmt(startDate)} au ${fmt(endDate)} est validé.</p>
<p>Le prestataire vous contactera pour organiser la remise du matériel sur place.</p>
<p><a href="${SITE_URL}/mes-locations" style="display:inline-block;background:#16a34a;color:white;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">Voir ma location</a></p>
<p style="font-size:11px;color:#71717a;">Référence : ${rentalBookingId}</p>`,
),
});
}
export async function sendPayoutSent(
to: string,
providerName: string,
periodMonth: Date,
amount: string,
reference: string | null,
): Promise<void> {
const monthLabel = periodMonth.toLocaleDateString("fr-FR", {
timeZone: "UTC",
year: "numeric",
month: "long",
});
await sendEmail({
to,
subject: `Reversement Karbé — ${monthLabel}`,
html: wrap(
`Reversement ${monthLabel}`,
`<p>Bonjour ${providerName},</p>
<p>Le reversement de vos locations matériel pour <strong>${monthLabel}</strong> a é effectué :</p>
<ul>
<li>Montant : <strong>${Number(amount).toFixed(2)} EUR</strong></li>
${reference ? `<li>Référence virement : <code>${reference}</code></li>` : ""}
</ul>
<p>Vérifiez votre compte bancaire dans les 1 à 3 jours ouvrés. En cas de question, répondez à cet email.</p>
<p><a href="${SITE_URL}/espace-prestataire/reservations" style="display:inline-block;background:#18181b;color:white;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">Voir mes réservations</a></p>`,
),
});
}
export async function sendBookingReminder(
to: string,
firstName: string,
bookingId: string,
carbetTitle: string,
startDate: Date,
carbetSlug: string,
): Promise<void> {
const dateFmt = new Intl.DateTimeFormat("fr-FR", {
day: "2-digit",
month: "long",
year: "numeric",
weekday: "long",
}).format(startDate);
await sendEmail({
to,
subject: `Demain : votre séjour ${carbetTitle}`,
html: wrap(
"Votre séjour démarre demain",
`<p>Bonjour ${firstName},</p>
<p>Votre séjour au carbet <strong>${carbetTitle}</strong> commence <strong>${dateFmt}</strong>.</p>
<p>Pensez à vérifier vos affaires : hamac, moustiquaire, frontale, eau, etc. Vérifiez aussi avec le loueur les détails d'arrivée (clés, dégrad, pirogue).</p>
<p><a href="${SITE_URL}/reservations/${bookingId}" style="display:inline-block;background:#18181b;color:white;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">Voir ma réservation</a></p>
<p><a href="${SITE_URL}/carbets/${carbetSlug}" style="font-size:12px;color:#71717a;">Détails du carbet</a></p>`,
),
});
}
export async function sendRentalReminder(
to: string,
firstName: string,
rentalBookingId: string,
providerName: string,
startDate: Date,
providerContact: { email: string | null; phone: string | null },
): Promise<void> {
const dateFmt = new Intl.DateTimeFormat("fr-FR", {
day: "2-digit",
month: "long",
weekday: "long",
}).format(startDate);
const contact = [
providerContact.phone ? `📞 ${providerContact.phone}` : null,
providerContact.email ? `${providerContact.email}` : null,
]
.filter(Boolean)
.join(" · ");
await sendEmail({
to,
subject: `Demain : récupération matériel ${providerName}`,
html: wrap(
"Récupération matériel demain",
`<p>Bonjour ${firstName},</p>
<p>Votre location matériel auprès de <strong>${providerName}</strong> démarre <strong>${dateFmt}</strong>.</p>
<p>Contactez le prestataire pour convenir du créneau et du lieu de remise.</p>
${contact ? `<p>${contact}</p>` : ""}
<p><a href="${SITE_URL}/mes-locations" style="display:inline-block;background:#18181b;color:white;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">Mes locations</a></p>
<p style="font-size:11px;color:#71717a;">Référence : ${rentalBookingId}</p>`,
),
});
}
export async function sendBookingRefunded(
to: string,
firstName: string,

218
src/lib/payouts.ts Normal file
View file

@ -0,0 +1,218 @@
import "server-only";
import { Prisma } from "@/generated/prisma/client";
import { RentalBookingStatus } from "@/generated/prisma/enums";
import { prisma } from "@/lib/prisma";
/**
* Politique de reversement v1 :
* Pour chaque RentalBooking CONFIRMED/HANDED_OVER/RETURNED créée pendant le mois M,
* le provider reçoit (itemsTotal + depositTotal - commissionAmount).
* Le marketplace garde la commission (commissionAmount) et la caution est restituée
* via le provider qui la collecte au remise (hors flux Stripe).
*
* En pratique : net du au provider = itemsTotal - commissionAmount.
* La caution n'est PAS comptée dans le reversement (le provider la collecte
* directement auprès du client).
*/
const COUNTED_STATUSES: RentalBookingStatus[] = [
RentalBookingStatus.CONFIRMED,
RentalBookingStatus.HANDED_OVER,
RentalBookingStatus.RETURNED,
];
export type ProviderPayout = {
providerId: string;
providerName: string;
isSystemD: boolean;
/** 1er du mois minuit UTC. */
periodMonth: Date;
bookingsCount: number;
/** Sum itemsTotal pour le provider × mois. */
grossAmount: number;
/** Sum commissionAmount. */
commission: number;
/** Net dû au provider = gross - commission. */
netAmount: number;
/** Mark déjà enregistrée si payé. */
paid: {
paidAt: Date;
amount: number;
reference: string | null;
paidByEmail: string | null;
} | null;
};
/**
* 1er jour du mois en UTC pour normalisation.
*/
export function monthKey(d: Date): Date {
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1));
}
export function formatMonth(d: Date): string {
return d.toLocaleDateString("fr-FR", {
timeZone: "UTC",
year: "numeric",
month: "long",
});
}
/**
* Calcule les reversements à effectuer sur les `monthsBack` derniers mois.
* - Exclut System D (commission 0 % et c'est l'asso qui gère).
* - Renvoie tous les providers actifs, même ceux à 0 (pour visibilité).
* - Inclut le statut payé/non payé depuis RentalPayoutMark.
*/
export async function listProviderPayouts(opts: {
monthsBack?: number;
} = {}): Promise<ProviderPayout[]> {
const monthsBack = opts.monthsBack ?? 6;
const now = new Date();
const earliest = monthKey(
new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - (monthsBack - 1), 1)),
);
const [providers, bookings, marks] = await Promise.all([
prisma.rentalProvider.findMany({
where: { isSystemD: false },
select: { id: true, name: true, isSystemD: true },
}),
prisma.rentalBooking.findMany({
where: {
status: { in: COUNTED_STATUSES },
createdAt: { gte: earliest },
provider: { isSystemD: false },
},
select: {
providerId: true,
createdAt: true,
itemsTotal: true,
commissionAmount: true,
},
}),
prisma.rentalPayoutMark.findMany({
where: { periodMonth: { gte: earliest } },
}),
]);
const result: ProviderPayout[] = [];
const monthsList: Date[] = [];
for (let i = 0; i < monthsBack; i++) {
monthsList.push(
monthKey(new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - i, 1))),
);
}
// Init grid provider × month avec zéros
type Cell = {
bookingsCount: number;
grossAmount: Prisma.Decimal;
commission: Prisma.Decimal;
};
const grid = new Map<string, Cell>();
const cellKey = (providerId: string, periodTs: number) => `${providerId}:${periodTs}`;
for (const p of providers) {
for (const m of monthsList) {
grid.set(cellKey(p.id, m.getTime()), {
bookingsCount: 0,
grossAmount: new Prisma.Decimal(0),
commission: new Prisma.Decimal(0),
});
}
}
// Aggrège les bookings
for (const b of bookings) {
const period = monthKey(b.createdAt);
const k = cellKey(b.providerId, period.getTime());
const cell = grid.get(k);
if (!cell) continue;
cell.bookingsCount++;
cell.grossAmount = cell.grossAmount.add(b.itemsTotal);
cell.commission = cell.commission.add(b.commissionAmount);
}
// Index des marks
const markIndex = new Map<string, (typeof marks)[number]>();
for (const m of marks) {
markIndex.set(cellKey(m.providerId, m.periodMonth.getTime()), m);
}
// Produit le résultat
for (const p of providers) {
for (const m of monthsList) {
const k = cellKey(p.id, m.getTime());
const cell = grid.get(k)!;
const net = cell.grossAmount.sub(cell.commission);
const mark = markIndex.get(k);
result.push({
providerId: p.id,
providerName: p.name,
isSystemD: p.isSystemD,
periodMonth: m,
bookingsCount: cell.bookingsCount,
grossAmount: cell.grossAmount.toDecimalPlaces(2).toNumber(),
commission: cell.commission.toDecimalPlaces(2).toNumber(),
netAmount: net.toDecimalPlaces(2).toNumber(),
paid: mark
? {
paidAt: mark.paidAt,
amount: Number(mark.amount),
reference: mark.reference,
paidByEmail: mark.paidByEmail,
}
: null,
});
}
}
// Tri : mois décroissant puis provider
return result.sort(
(a, b) =>
b.periodMonth.getTime() - a.periodMonth.getTime() ||
a.providerName.localeCompare(b.providerName, "fr"),
);
}
/**
* Crée un RentalPayoutMark (idempotent via unique constraint provider+period).
*/
export async function createPayoutMark(opts: {
providerId: string;
periodMonth: Date;
amount: number;
reference?: string | null;
paidByEmail: string | null;
}): Promise<{ ok: true; alreadyExists: boolean } | { ok: false; error: string }> {
const period = monthKey(opts.periodMonth);
const existing = await prisma.rentalPayoutMark.findUnique({
where: { providerId_periodMonth: { providerId: opts.providerId, periodMonth: period } },
select: { id: true },
});
if (existing) return { ok: true, alreadyExists: true };
await prisma.rentalPayoutMark.create({
data: {
providerId: opts.providerId,
periodMonth: period,
amount: new Prisma.Decimal(opts.amount),
reference: opts.reference ?? null,
paidByEmail: opts.paidByEmail,
},
});
return { ok: true, alreadyExists: false };
}
export async function deletePayoutMark(
providerId: string,
periodMonth: Date,
): Promise<void> {
const period = monthKey(periodMonth);
await prisma.rentalPayoutMark
.delete({
where: { providerId_periodMonth: { providerId, periodMonth: period } },
})
.catch(() => {});
}

View file

@ -15,6 +15,7 @@ export interface PluginHookSet {
}
import { archiveDemoCarbets, seedDemoCarbets } from "./seeds/demo-carbets";
import { archiveDemoCe, seedDemoCe } from "./seeds/demo-ce";
import {
republishContentPages,
seedContentPages,
@ -134,4 +135,18 @@ export const pluginHooks: Record<string, PluginHookSet | undefined> = {
);
},
},
"demo-ce-seed": {
onEnable: async () => {
const { created, orgId } = await seedDemoCe();
console.log(
`[plugin demo-ce-seed] ${created ? "créé" : "déjà présent"}: orgId=${orgId}`,
);
},
onDisable: async () => {
const { deletedUsers, deletedOrg } = await archiveDemoCe();
console.log(
`[plugin demo-ce-seed] disable: ${deletedUsers} users supprimés, ${deletedOrg} org supprimée(s)`,
);
},
},
};

View file

@ -109,6 +109,22 @@ export const PLUGINS: PluginDescriptor[] = [
category: "business",
version: "0.1.0",
},
{
key: "gear-rental",
name: "Location matériel (sous-marketplace)",
description:
"Catalogue matériel (hamac, moustiquaire, pirogue, kayak…) loué par System D et prestataires tiers. Inclut panier, checkout Stripe, espace prestataire, recommandations carbet. Si désactivé : /materiel, /espace-prestataire et /mes-locations renvoient 404; liens header masqués.",
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
{
@ -133,6 +149,14 @@ export const PLUGINS: PluginDescriptor[] = [
category: "content",
version: "0.1.0",
},
{
key: "demo-ce-seed",
name: "Démo Comité d'Entreprise",
description:
"Seed une organisation CE démo (Comité ESA Kourou) avec 2 managers, 3 membres, 2 carbets co-gérés et 1 provider rental org-scoped + 4 items. Utile pour visualiser le module CE sans signup manuel. Disable : carbets archivés, users + org supprimés. Dépend de `ce-management`.",
category: "visual",
version: "0.1.0",
},
];
export const PLUGIN_KEYS = PLUGINS.map((p) => p.key);

View file

@ -0,0 +1,234 @@
/**
* Seed du plugin `demo-ce-seed`.
*
* Crée une organisation démo « Comité ESA Kourou » (approved=true) avec :
* - 2 CE_MANAGERs et 3 CE_MEMBERs (password "demo")
* - 2 carbets co-gérés (ownerId = manager#1) avec OrganizationCarbetMembership
* - 1 RentalProvider org-scoped avec 4 items
*
* Idempotent : check d'existence par email/slug stables avant chaque création.
* Disable : soft cleanup en cascade (les emails @karbe.demo sont supprimés,
* la cascade Prisma se charge des memberships, items, etc.).
*/
import {
CarbetStatus,
RentalCategory,
UserRole,
} from "@/generated/prisma/enums";
import { hashPassword } from "@/lib/password";
import { prisma } from "@/lib/prisma";
const DEMO_ORG_SLUG = "demo-comite-esa-kourou";
const DEMO_PROVIDER_NAME = "Matériel — Comité ESA Kourou (démo)";
const DEMO_USERS = [
{ email: "demo-ce-mgr1@karbe.demo", firstName: "Aline", lastName: "Spaceport", role: UserRole.CE_MANAGER },
{ email: "demo-ce-mgr2@karbe.demo", firstName: "Bruno", lastName: "Soyouz", role: UserRole.CE_MANAGER },
{ email: "demo-ce-mbr1@karbe.demo", firstName: "Clara", lastName: "Ariane", role: UserRole.CE_MEMBER },
{ email: "demo-ce-mbr2@karbe.demo", firstName: "David", lastName: "Vega", role: UserRole.CE_MEMBER },
{ email: "demo-ce-mbr3@karbe.demo", firstName: "Élodie", lastName: "Falcon", role: UserRole.CE_MEMBER },
] as const;
const DEMO_CARBETS = [
{
slug: "demo-ce-karbe-sinnamary",
title: "Karbé CE Sinnamary",
river: "Sinnamary",
embarkPoint: "Dégrad Pointe Combi",
latitude: 5.39,
longitude: -53.0,
capacity: 6,
nightlyPrice: 95,
description:
"Carbet du Comité ESA Kourou sur la Sinnamary, accessible par 1h de pirogue depuis le dégrad. Réservé en priorité aux membres du CE le week-end, ouvert au public en semaine.",
},
{
slug: "demo-ce-karbe-kourou",
title: "Karbé CE Tonate",
river: "Kourou",
embarkPoint: "Embarcadère Tonate",
latitude: 5.25,
longitude: -52.65,
capacity: 8,
nightlyPrice: 110,
description:
"Carbet d'entreprise sur le fleuve Kourou, accessible en voiture jusqu'au dégrad. Équipé pour 8 voyageurs.",
},
] as const;
const DEMO_RENTAL_ITEMS = [
{ name: "Hamac coton 2 places (démo CE)", category: RentalCategory.SLEEP, pricePerDay: 5, deposit: 15, totalQty: 12 },
{ name: "Moustiquaire fleuve (démo CE)", category: RentalCategory.SLEEP, pricePerDay: 3, deposit: 10, totalQty: 12 },
{ name: "Kayak monoplace (démo CE)", category: RentalCategory.NAVIGATION, pricePerDay: 35, deposit: 200, totalQty: 4 },
{ name: "Réchaud gaz (démo CE)", category: RentalCategory.COOKING, pricePerDay: 6, deposit: 30, totalQty: 5 },
] as const;
export async function seedDemoCe(): Promise<{ created: boolean; orgId: string }> {
// 1. Organisation (idempotent par slug)
const existing = await prisma.organization.findUnique({
where: { slug: DEMO_ORG_SLUG },
select: { id: true },
});
if (existing) {
return { created: false, orgId: existing.id };
}
const passwordHash = await hashPassword("demo");
const org = await prisma.organization.create({
data: {
name: "Comité ESA Kourou (démo)",
slug: DEMO_ORG_SLUG,
description:
"Comité d'entreprise démo (fictif). Démontre la co-gestion de carbets et la location matériel par un CE sur Karbé.",
contactEmail: "demo-ce-mgr1@karbe.demo",
approved: true,
approvedAt: new Date(),
approvedBy: "demo-seed",
},
select: { id: true },
});
// 2. Membres
const users: { id: string; role: UserRole }[] = [];
for (const u of DEMO_USERS) {
const created = await prisma.user.upsert({
where: { email: u.email },
update: { organizationId: org.id, role: u.role },
create: {
email: u.email,
passwordHash,
firstName: u.firstName,
lastName: u.lastName,
role: u.role,
organizationId: org.id,
isActive: true,
},
select: { id: true, role: true },
});
users.push(created);
}
const mgr1 = users[0]!;
// 3. Carbets + memberships
for (const c of DEMO_CARBETS) {
const existingCarbet = await prisma.carbet.findUnique({
where: { slug: c.slug },
select: { id: true },
});
if (existingCarbet) {
// S'assure que la membership existe
await prisma.organizationCarbetMembership
.upsert({
where: { organizationId_carbetId: { organizationId: org.id, carbetId: existingCarbet.id } },
update: {},
create: { organizationId: org.id, carbetId: existingCarbet.id, addedByUserId: mgr1.id },
});
continue;
}
const carbet = await prisma.carbet.create({
data: {
ownerId: mgr1.id,
title: c.title,
slug: c.slug,
description: c.description,
river: c.river,
latitude: c.latitude,
longitude: c.longitude,
embarkPoint: c.embarkPoint,
pirogueDurationMin: 60,
capacity: c.capacity,
nightlyPrice: c.nightlyPrice,
status: CarbetStatus.PUBLISHED,
},
select: { id: true },
});
await prisma.organizationCarbetMembership.create({
data: { organizationId: org.id, carbetId: carbet.id, addedByUserId: mgr1.id },
});
}
// 4. RentalProvider org-scoped + items
let provider = await prisma.rentalProvider.findFirst({
where: { organizationId: org.id },
select: { id: true },
});
if (!provider) {
provider = await prisma.rentalProvider.create({
data: {
name: DEMO_PROVIDER_NAME,
isSystemD: false,
managedByUserId: mgr1.id,
organizationId: org.id,
contactEmail: "demo-ce-mgr1@karbe.demo",
rivers: ["Sinnamary", "Kourou"],
commissionPct: 10,
active: true,
approved: true,
approvedAt: new Date(),
approvedBy: "demo-seed",
},
select: { id: true },
});
}
for (const item of DEMO_RENTAL_ITEMS) {
const existingItem = await prisma.rentalItem.findFirst({
where: { providerId: provider.id, name: item.name },
select: { id: true },
});
if (existingItem) continue;
await prisma.rentalItem.create({
data: {
providerId: provider.id,
category: item.category,
name: item.name,
pricePerDay: item.pricePerDay,
deposit: item.deposit,
totalQty: item.totalQty,
active: true,
},
});
}
return { created: true, orgId: org.id };
}
export async function archiveDemoCe(): Promise<{ deletedUsers: number; deletedOrg: number }> {
// Cascade Prisma : Org delete → memberships + invites cascade ;
// RentalProvider.organizationId → SetNull (l'orga disparaît, le provider
// reste rattaché au manager nominal, on le supprime explicitement).
// Users → on supprime les emails @karbe.demo (cascade RentalProvider via
// managedByUserId=SetNull, mais on garde les carbets ; archive les carbets
// démo via status=ARCHIVED pour pas casser les bookings historiques).
const org = await prisma.organization.findUnique({
where: { slug: DEMO_ORG_SLUG },
select: { id: true },
});
if (!org) return { deletedUsers: 0, deletedOrg: 0 };
// Soft-archive les carbets démo
await prisma.carbet.updateMany({
where: { slug: { in: DEMO_CARBETS.map((c) => c.slug) } },
data: { status: CarbetStatus.ARCHIVED },
});
// Supprime le RentalProvider démo (cascade items + bookings → onDelete:Restrict
// sur bookings, donc skip si des bookings existent)
await prisma.rentalProvider
.deleteMany({ where: { organizationId: org.id, name: DEMO_PROVIDER_NAME } })
.catch(() => {});
// Supprime les users démo (cascade memberships)
const { count: deletedUsers } = await prisma.user.deleteMany({
where: { email: { endsWith: "@karbe.demo", in: DEMO_USERS.map((u) => u.email) } },
});
// Supprime l'org (cascade memberships restantes)
const { count: deletedOrg } = await prisma.organization.deleteMany({
where: { slug: DEMO_ORG_SLUG },
});
return { deletedUsers, deletedOrg };
}

Some files were not shown because too many files have changed in this diff Show more