diff --git a/prisma/migrations/20260602030000_operational_criteria/migration.sql b/prisma/migrations/20260602030000_operational_criteria/migration.sql deleted file mode 100644 index 5bdca5f..0000000 --- a/prisma/migrations/20260602030000_operational_criteria/migration.sql +++ /dev/null @@ -1,15 +0,0 @@ -CREATE TYPE "RoadAccess" AS ENUM ('NONE', 'DRY_SEASON_ONLY', 'ALL_YEAR'); -CREATE TYPE "Electricity" AS ENUM ('NONE', 'SOLAR', 'GENERATOR_READY', 'EDF'); - -ALTER TABLE "Carbet" ADD COLUMN "roadAccess" "RoadAccess"; -ALTER TABLE "Carbet" ADD COLUMN "electricity" "Electricity"; -ALTER TABLE "Carbet" ADD COLUMN "gsmAtCarbet" BOOLEAN NOT NULL DEFAULT false; -ALTER TABLE "Carbet" ADD COLUMN "gsmExitDistanceKm" DECIMAL(4,2); - --- Seed des 6 carbets démo avec valeurs réalistes -UPDATE "Carbet" SET "roadAccess" = 'NONE', "electricity" = 'SOLAR', "gsmAtCarbet" = false, "gsmExitDistanceKm" = 1.5 WHERE id = 'demo-carbet-awara'; -UPDATE "Carbet" SET "roadAccess" = 'ALL_YEAR', "electricity" = 'EDF', "gsmAtCarbet" = true WHERE id = 'demo-carbet-kourou'; -UPDATE "Carbet" SET "roadAccess" = 'ALL_YEAR', "electricity" = 'EDF', "gsmAtCarbet" = true WHERE id = 'demo-carbet-mahury'; -UPDATE "Carbet" SET "roadAccess" = 'NONE', "electricity" = 'GENERATOR_READY', "gsmAtCarbet" = false, "gsmExitDistanceKm" = 4.0 WHERE id = 'demo-carbet-maripa'; -UPDATE "Carbet" SET "roadAccess" = 'DRY_SEASON_ONLY', "electricity" = 'SOLAR', "gsmAtCarbet" = false, "gsmExitDistanceKm" = 0.5 WHERE id = 'demo-carbet-paripou'; -UPDATE "Carbet" SET "roadAccess" = 'ALL_YEAR', "electricity" = 'EDF', "gsmAtCarbet" = true WHERE id = 'demo-carbet-wapa'; diff --git a/prisma/migrations/20260603000000_rental_marketplace/migration.sql b/prisma/migrations/20260603000000_rental_marketplace/migration.sql deleted file mode 100644 index 65b4eb1..0000000 --- a/prisma/migrations/20260603000000_rental_marketplace/migration.sql +++ /dev/null @@ -1,112 +0,0 @@ --- UserRole : ajouter RENTAL_PROVIDER -ALTER TYPE "UserRole" ADD VALUE IF NOT EXISTS 'RENTAL_PROVIDER'; - --- Enums dédiés -CREATE TYPE "RentalCategory" AS ENUM ('SLEEP', 'NAVIGATION', 'FISHING', 'COOKING', 'SAFETY'); -CREATE TYPE "RentalBookingStatus" AS ENUM ('PENDING', 'CONFIRMED', 'HANDED_OVER', 'RETURNED', 'CANCELLED'); - --- RentalProvider -CREATE TABLE "RentalProvider" ( - "id" TEXT NOT NULL, - "name" TEXT NOT NULL, - "isSystemD" BOOLEAN NOT NULL DEFAULT false, - "managedByUserId" TEXT, - "contactEmail" TEXT, - "contactPhone" TEXT, - "rivers" TEXT[] DEFAULT ARRAY[]::TEXT[], - "description" TEXT, - "commissionPct" DECIMAL(5,2) NOT NULL DEFAULT 0, - "active" BOOLEAN NOT NULL DEFAULT true, - "approved" BOOLEAN NOT NULL DEFAULT false, - "approvedAt" TIMESTAMP(3), - "approvedBy" TEXT, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - CONSTRAINT "RentalProvider_pkey" PRIMARY KEY ("id"), - CONSTRAINT "RentalProvider_managedByUserId_fkey" FOREIGN KEY ("managedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE -); -CREATE INDEX "RentalProvider_active_approved_idx" ON "RentalProvider"("active", "approved"); -CREATE INDEX "RentalProvider_managedByUserId_idx" ON "RentalProvider"("managedByUserId"); - --- RentalItem -CREATE TABLE "RentalItem" ( - "id" TEXT NOT NULL, - "providerId" TEXT NOT NULL, - "category" "RentalCategory" NOT NULL, - "name" TEXT NOT NULL, - "description" TEXT, - "imageUrl" TEXT, - "pricePerDay" DECIMAL(8,2) NOT NULL, - "pricePerWeek" DECIMAL(8,2), - "deposit" DECIMAL(8,2) NOT NULL DEFAULT 0, - "totalQty" INTEGER NOT NULL DEFAULT 1, - "withMotor" BOOLEAN NOT NULL DEFAULT false, - "fuelIncluded" BOOLEAN NOT NULL DEFAULT false, - "requiresLicense" BOOLEAN NOT NULL DEFAULT false, - "active" BOOLEAN NOT NULL DEFAULT true, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - CONSTRAINT "RentalItem_pkey" PRIMARY KEY ("id"), - CONSTRAINT "RentalItem_providerId_fkey" FOREIGN KEY ("providerId") REFERENCES "RentalProvider"("id") ON DELETE CASCADE ON UPDATE CASCADE -); -CREATE INDEX "RentalItem_providerId_idx" ON "RentalItem"("providerId"); -CREATE INDEX "RentalItem_category_active_idx" ON "RentalItem"("category", "active"); - --- RentalItemAvailability -CREATE TABLE "RentalItemAvailability" ( - "id" TEXT NOT NULL, - "itemId" TEXT NOT NULL, - "startDate" TIMESTAMP(3) NOT NULL, - "endDate" TIMESTAMP(3) NOT NULL, - "qty" INTEGER NOT NULL, - "reason" TEXT NOT NULL, - "rentalBookingId" TEXT, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT "RentalItemAvailability_pkey" PRIMARY KEY ("id"), - CONSTRAINT "RentalItemAvailability_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "RentalItem"("id") ON DELETE CASCADE ON UPDATE CASCADE -); -CREATE INDEX "RentalItemAvailability_itemId_startDate_endDate_idx" ON "RentalItemAvailability"("itemId", "startDate", "endDate"); -CREATE INDEX "RentalItemAvailability_rentalBookingId_idx" ON "RentalItemAvailability"("rentalBookingId"); - --- RentalBooking -CREATE TABLE "RentalBooking" ( - "id" TEXT NOT NULL, - "bookingId" TEXT, - "tenantId" TEXT NOT NULL, - "providerId" TEXT NOT NULL, - "startDate" TIMESTAMP(3) NOT NULL, - "endDate" TIMESTAMP(3) NOT NULL, - "status" "RentalBookingStatus" NOT NULL DEFAULT 'PENDING', - "paymentStatus" "PaymentStatus" NOT NULL DEFAULT 'PENDING', - "itemsTotal" DECIMAL(10,2) NOT NULL, - "depositTotal" DECIMAL(10,2) NOT NULL, - "commissionAmount" DECIMAL(10,2) NOT NULL DEFAULT 0, - "amount" DECIMAL(10,2) NOT NULL, - "currency" TEXT NOT NULL DEFAULT 'EUR', - "stripeSessionId" TEXT, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - CONSTRAINT "RentalBooking_pkey" PRIMARY KEY ("id"), - CONSTRAINT "RentalBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE SET NULL ON UPDATE CASCADE, - CONSTRAINT "RentalBooking_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE, - CONSTRAINT "RentalBooking_providerId_fkey" FOREIGN KEY ("providerId") REFERENCES "RentalProvider"("id") ON DELETE RESTRICT ON UPDATE CASCADE -); -CREATE INDEX "RentalBooking_tenantId_status_idx" ON "RentalBooking"("tenantId", "status"); -CREATE INDEX "RentalBooking_providerId_status_idx" ON "RentalBooking"("providerId", "status"); -CREATE INDEX "RentalBooking_bookingId_idx" ON "RentalBooking"("bookingId"); -CREATE INDEX "RentalBooking_startDate_endDate_idx" ON "RentalBooking"("startDate", "endDate"); - --- RentalLine -CREATE TABLE "RentalLine" ( - "id" TEXT NOT NULL, - "rentalBookingId" TEXT NOT NULL, - "itemId" TEXT NOT NULL, - "qty" INTEGER NOT NULL, - "pricePerDay" DECIMAL(8,2) NOT NULL, - "deposit" DECIMAL(8,2) NOT NULL DEFAULT 0, - "lineTotal" DECIMAL(10,2) NOT NULL, - CONSTRAINT "RentalLine_pkey" PRIMARY KEY ("id"), - CONSTRAINT "RentalLine_rentalBookingId_fkey" FOREIGN KEY ("rentalBookingId") REFERENCES "RentalBooking"("id") ON DELETE CASCADE ON UPDATE CASCADE, - CONSTRAINT "RentalLine_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "RentalItem"("id") ON DELETE RESTRICT ON UPDATE CASCADE -); -CREATE INDEX "RentalLine_rentalBookingId_idx" ON "RentalLine"("rentalBookingId"); diff --git a/prisma/migrations/20260603100000_rental_item_media/migration.sql b/prisma/migrations/20260603100000_rental_item_media/migration.sql deleted file mode 100644 index 67a2d76..0000000 --- a/prisma/migrations/20260603100000_rental_item_media/migration.sql +++ /dev/null @@ -1,22 +0,0 @@ --- 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; diff --git a/prisma/migrations/20260603200000_ce_management/migration.sql b/prisma/migrations/20260603200000_ce_management/migration.sql deleted file mode 100644 index eb2cc87..0000000 --- a/prisma/migrations/20260603200000_ce_management/migration.sql +++ /dev/null @@ -1,54 +0,0 @@ --- 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; diff --git a/prisma/migrations/20260603300000_org_invite_token/migration.sql b/prisma/migrations/20260603300000_org_invite_token/migration.sql deleted file mode 100644 index 7ce99e5..0000000 --- a/prisma/migrations/20260603300000_org_invite_token/migration.sql +++ /dev/null @@ -1,22 +0,0 @@ --- 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; diff --git a/prisma/migrations/20260603400000_rental_payout_mark/migration.sql b/prisma/migrations/20260603400000_rental_payout_mark/migration.sql deleted file mode 100644 index cff28de..0000000 --- a/prisma/migrations/20260603400000_rental_payout_mark/migration.sql +++ /dev/null @@ -1,28 +0,0 @@ --- 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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6ae7e3f..83d75c2 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -13,7 +13,6 @@ enum UserRole { CE_MEMBER TOURIST ADMIN - RENTAL_PROVIDER } enum CarbetStatus { @@ -72,59 +71,16 @@ enum TransportMode { } model Organization { - 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 + id String @id @default(cuid()) + name String + slug String @unique + description String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt - members User[] - carbetMemberships OrganizationCarbetMembership[] - rentalProviders RentalProvider[] - invites OrgInviteToken[] + members User[] @@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 { @@ -141,13 +97,11 @@ model User { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - organization Organization? @relation(fields: [organizationId], references: [id], onDelete: SetNull) - carbets Carbet[] @relation("CarbetOwner") - bookings Booking[] @relation("BookingTenant") - reviews Review[] @relation("ReviewAuthor") - subscriptions Subscription[] - rentalProviders RentalProvider[] - rentalBookings RentalBooking[] @relation("RentalBookingTenant") + organization Organization? @relation(fields: [organizationId], references: [id], onDelete: SetNull) + carbets Carbet[] @relation("CarbetOwner") + bookings Booking[] @relation("BookingTenant") + reviews Review[] @relation("ReviewAuthor") + subscriptions Subscription[] @@index([organizationId]) @@index([role]) @@ -170,11 +124,6 @@ model Carbet { // Détails d'accès route pour ROAD_AND_RIVER (GPS, distance, type de piste). roadAccessNote String? capacity Int - // 4 critères opérationnels dealbreakers (dispo en filtres + badges UI) - roadAccess RoadAccess? - electricity Electricity? - gsmAtCarbet Boolean @default(false) - gsmExitDistanceKm Decimal? @db.Decimal(4, 2) // Prix par nuit pour le carbet entier (toute capacité). En euros. nightlyPrice Decimal @db.Decimal(10, 2) @default(0) // Contraintes séjour (plugin min-stay). null = pas de contrainte. @@ -200,7 +149,6 @@ model Carbet { bookings Booking[] reviews Review[] subscriptions Subscription[] - organizations OrganizationCarbetMembership[] @@index([ownerId]) @@index([status]) @@ -296,8 +244,7 @@ model Booking { carbet Carbet @relation(fields: [carbetId], references: [id], onDelete: Restrict) tenant User @relation("BookingTenant", fields: [tenantId], references: [id], onDelete: Restrict) - review Review? - rentalBookings RentalBooking[] + review Review? @@index([carbetId]) @@index([tenantId]) @@ -434,186 +381,3 @@ model Favorite { @@index([userId]) @@index([carbetId]) } - -enum RoadAccess { - NONE - DRY_SEASON_ONLY - ALL_YEAR -} - -enum Electricity { - NONE - SOLAR - GENERATOR_READY - EDF -} - -enum RentalCategory { - SLEEP - NAVIGATION - FISHING - COOKING - SAFETY -} - -enum RentalBookingStatus { - PENDING - CONFIRMED - HANDED_OVER - RETURNED - CANCELLED -} - -model RentalProvider { - id String @id @default(cuid()) - 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([]) - description String? - commissionPct Decimal @db.Decimal(5, 2) @default(0) - active Boolean @default(true) - approved Boolean @default(false) - approvedAt DateTime? - approvedBy String? - createdAt DateTime @default(now()) - 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 { - id String @id @default(cuid()) - providerId String - category RentalCategory - name String - description String? - imageUrl String? - pricePerDay Decimal @db.Decimal(8, 2) - pricePerWeek Decimal? @db.Decimal(8, 2) - deposit Decimal @db.Decimal(8, 2) @default(0) - totalQty Int @default(1) - withMotor Boolean @default(false) - fuelIncluded Boolean @default(false) - requiresLicense Boolean @default(false) - active Boolean @default(true) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - 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 - startDate DateTime - endDate DateTime - qty Int - reason String - rentalBookingId String? - createdAt DateTime @default(now()) - - item RentalItem @relation(fields: [itemId], references: [id], onDelete: Cascade) - - @@index([itemId, startDate, endDate]) - @@index([rentalBookingId]) -} - -model RentalBooking { - id String @id @default(cuid()) - bookingId String? - tenantId String - providerId String - startDate DateTime - endDate DateTime - status RentalBookingStatus @default(PENDING) - paymentStatus PaymentStatus @default(PENDING) - itemsTotal Decimal @db.Decimal(10, 2) - depositTotal Decimal @db.Decimal(10, 2) - commissionAmount Decimal @db.Decimal(10, 2) @default(0) - amount Decimal @db.Decimal(10, 2) - currency String @default("EUR") - stripeSessionId String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - booking Booking? @relation(fields: [bookingId], references: [id], onDelete: SetNull) - tenant User @relation("RentalBookingTenant", fields: [tenantId], references: [id], onDelete: Restrict) - provider RentalProvider @relation(fields: [providerId], references: [id], onDelete: Restrict) - lines RentalLine[] - - @@index([tenantId, status]) - @@index([providerId, status]) - @@index([bookingId]) - @@index([startDate, endDate]) -} - -model RentalLine { - id String @id @default(cuid()) - rentalBookingId String - itemId String - qty Int - pricePerDay Decimal @db.Decimal(8, 2) - deposit Decimal @db.Decimal(8, 2) @default(0) - lineTotal Decimal @db.Decimal(10, 2) - - rentalBooking RentalBooking @relation(fields: [rentalBookingId], references: [id], onDelete: Cascade) - item RentalItem @relation(fields: [itemId], references: [id], onDelete: Restrict) - - @@index([rentalBookingId]) -} diff --git a/src/app/admin/analytics/page.tsx b/src/app/admin/analytics/page.tsx deleted file mode 100644 index 0ec2427..0000000 --- a/src/app/admin/analytics/page.tsx +++ /dev/null @@ -1,169 +0,0 @@ -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 = { - 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 ( -
-
-

Analytics globaux

-

- Vue d'ensemble plateforme : utilisateurs, activité 30 derniers jours, top performers. -

-
- -
- - - - -
- -
-
-

- Utilisateurs par rôle -

- {kpis.usersTotal === 0 ? ( -

Aucun utilisateur.

- ) : ( -
    - {Object.entries(kpis.usersByRole) - .sort((a, b) => b[1] - a[1]) - .map(([role, count]) => { - const pct = Math.round((count / kpis.usersTotal) * 100); - return ( -
  • -
    - {ROLE_LABEL[role] ?? role} - - {count} ({pct}%) - -
    -
    -
    -
    -
  • - ); - })} -
- )} -
- -
-

- Activité 30 derniers jours -

-
    -
  • - Bookings carbet - {kpis.bookings30d} -
  • -
  • - Locations matériel - {kpis.rentals30d} -
  • -
  • - Total CA 30j - - {fmtEur(kpis.revenue30d)} - -
  • -
-
-
- -
-

- Chiffre d'affaires mensuel -

- -
- -
-
-

- Top carbets (30j) -

- {kpis.topCarbets.length === 0 ? ( -

Aucune réservation sur les 30 derniers jours.

- ) : ( -
    - {kpis.topCarbets.map((c, i) => ( -
  • - - #{i + 1} - - {c.title} - - - {fmtEur(c.revenue)} -
  • - ))} -
- )} -
- -
-

- Top prestataires rental (30j) -

- {kpis.topProviders.length === 0 ? ( -

Aucune location sur les 30 derniers jours.

- ) : ( -
    - {kpis.topProviders.map((p, i) => ( -
  • - - #{i + 1} - - {p.name} - - - {fmtEur(p.revenue)} -
  • - ))} -
- )} -
-
-
- ); -} - -function KpiCard({ label, value }: { label: string; value: string | number }) { - return ( -
-
{label}
-
{value}
-
- ); -} diff --git a/src/app/admin/carbets/[id]/_components/CarbetMemberships.tsx b/src/app/admin/carbets/[id]/_components/CarbetMemberships.tsx deleted file mode 100644 index 95bfc88..0000000 --- a/src/app/admin/carbets/[id]/_components/CarbetMemberships.tsx +++ /dev/null @@ -1,125 +0,0 @@ -"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(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 ( -
- {linked.length === 0 ? ( -

- Aucune organisation liée. Le carbet est géré uniquement par son propriétaire individuel. -

- ) : ( -
    - {linked.map((o) => ( -
  • -
    - {o.name} - /{o.slug} - {!o.approved ? ( - - Pending - - ) : null} -
    - -
  • - ))} -
- )} - - {options.length > 0 ? ( -
- - -
- ) : ( -

- Toutes les organisations existantes sont déjà liées à ce carbet. -

- )} - - {error ? ( -
- {error} -
- ) : null} - -

- Une organisation liée signifie que ses CE_MANAGERs peuvent éditer ce carbet en plus du - propriétaire nominal. -

-
- ); -} diff --git a/src/app/admin/carbets/[id]/page.tsx b/src/app/admin/carbets/[id]/page.tsx index 6a69e2e..7799bef 100644 --- a/src/app/admin/carbets/[id]/page.tsx +++ b/src/app/admin/carbets/[id]/page.tsx @@ -1,23 +1,15 @@ 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 { - linkCarbetToOrganizationAction, - unlinkCarbetFromOrganizationAction, - updateCarbetAction, -} from "../actions"; -import { CarbetMemberships } from "./_components/CarbetMemberships"; +import { StatusBadge } from "@/components/admin/StatusBadge"; +import { MediaUploader } from "@/components/MediaUploader"; import { StatusActions } from "./_components/StatusActions"; +import { updateCarbetAction } from "../actions"; export const dynamic = "force-dynamic"; @@ -25,11 +17,10 @@ type PageProps = { params: Promise<{ id: string }> }; export default async function EditCarbetPage({ params }: PageProps) { const { id } = await params; - const [carbet, owners, providers, organizations] = await Promise.all([ + const [carbet, owners, providers] = await Promise.all([ getCarbetForEdit(id), listOwners(), listPirogueProviders(), - listOrganizationsForLink(), ]); if (!carbet) notFound(); @@ -37,14 +28,6 @@ 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 (
@@ -78,25 +61,6 @@ export default async function EditCarbetPage({ params }: PageProps) {
-
-

- Organisations co-gestionnaires (CE) -

- ({ - 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} - /> -
-

Médias @@ -130,10 +94,6 @@ export default async function EditCarbetPage({ params }: PageProps) { capacity: carbet.capacity, nightlyPrice: carbet.nightlyPrice.toString(), accessType: carbet.accessType, - roadAccess: carbet.roadAccess, - electricity: carbet.electricity, - gsmAtCarbet: carbet.gsmAtCarbet, - gsmExitDistanceKm: carbet.gsmExitDistanceKm !== null ? carbet.gsmExitDistanceKm.toString() : null, roadAccessNote: carbet.roadAccessNote, pirogueDurationMin: carbet.pirogueDurationMin, minStayNights: carbet.minStayNights, diff --git a/src/app/admin/carbets/_components/CarbetForm.tsx b/src/app/admin/carbets/_components/CarbetForm.tsx index 4ddabe8..260996b 100644 --- a/src/app/admin/carbets/_components/CarbetForm.tsx +++ b/src/app/admin/carbets/_components/CarbetForm.tsx @@ -20,10 +20,6 @@ export type CarbetFormInitial = { capacity?: number; nightlyPrice?: number | string; accessType?: string; - roadAccess?: string | null; - electricity?: string | null; - gsmAtCarbet?: boolean; - gsmExitDistanceKm?: number | string | null; roadAccessNote?: string | null; pirogueDurationMin?: number | null; minStayNights?: number | null; @@ -193,63 +189,6 @@ export function CarbetForm({ initial = {}, owners, providers, action, submitLabe

- {/* Critères opérationnels */} -
-

- Critères opérationnels -

-

- Les 4 dealbreakers d'un séjour en carbet guyanais. Indispensable pour les filtres recherche. -

-
- - - - - - - - - - - - - - - -
-
- {/* Séjour & tarif */}

Séjour & tarif

diff --git a/src/app/admin/carbets/actions.ts b/src/app/admin/carbets/actions.ts index f85950a..9e2fbff 100644 --- a/src/app/admin/carbets/actions.ts +++ b/src/app/admin/carbets/actions.ts @@ -10,9 +10,7 @@ import { prisma } from "@/lib/prisma"; import { AccessType, CarbetStatus, - Electricity, MediaType, - RoadAccess, TransportMode, UserRole, } from "@/generated/prisma/enums"; @@ -31,16 +29,6 @@ const baseCarbetSchema = z.object({ capacity: z.coerce.number().int().min(1).max(100), nightlyPrice: z.coerce.number().min(0).max(100000), accessType: z.enum([AccessType.ROAD_AND_RIVER, AccessType.RIVER_ONLY]), - roadAccess: z - .enum([RoadAccess.NONE, RoadAccess.DRY_SEASON_ONLY, RoadAccess.ALL_YEAR]) - .optional() - .nullable(), - electricity: z - .enum([Electricity.NONE, Electricity.SOLAR, Electricity.GENERATOR_READY, Electricity.EDF]) - .optional() - .nullable(), - gsmAtCarbet: z.preprocess((v) => v === "yes" || v === true, z.boolean()), - gsmExitDistanceKm: z.coerce.number().min(0).max(50).optional().nullable(), roadAccessNote: z.string().trim().max(1000).optional().nullable(), pirogueDurationMin: z.coerce.number().int().min(0).max(1440).optional().nullable(), minStayNights: z.coerce.number().int().min(1).max(365).optional().nullable(), @@ -65,11 +53,9 @@ function parseFromFormData(fd: FormData) { if (typeof v === "string") obj[k] = v; } // Normalise les champs optionnels nullables - ["roadAccessNote", "pirogueDurationMin", "minStayNights", "maxStayNights", "minCapacity", "transportMode", "pirogueProviderId", "roadAccess", "electricity", "gsmExitDistanceKm"].forEach( + ["roadAccessNote", "pirogueDurationMin", "minStayNights", "maxStayNights", "minCapacity", "transportMode", "pirogueProviderId"].forEach( (k) => (obj[k] = normalizeNullable(obj[k] as string | null | undefined)), ); - // gsmAtCarbet : si pas posté, on garde la valeur (sera traité par preprocess Zod) - if (!("gsmAtCarbet" in obj)) obj.gsmAtCarbet = "no"; return obj; } @@ -213,42 +199,6 @@ 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, diff --git a/src/app/admin/organizations/[id]/_components/ApproveOrgButton.tsx b/src/app/admin/organizations/[id]/_components/ApproveOrgButton.tsx deleted file mode 100644 index d53a21c..0000000 --- a/src/app/admin/organizations/[id]/_components/ApproveOrgButton.tsx +++ /dev/null @@ -1,36 +0,0 @@ -"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(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 ( -
- - {error ? {error} : null} -
- ); -} diff --git a/src/app/admin/organizations/[id]/page.tsx b/src/app/admin/organizations/[id]/page.tsx index 810ba23..90c91b6 100644 --- a/src/app/admin/organizations/[id]/page.tsx +++ b/src/app/admin/organizations/[id]/page.tsx @@ -3,8 +3,7 @@ import Link from "next/link"; import { getOrganizationForAdmin } from "@/lib/admin/organizations"; import { OrgForm } from "../_components/OrgForm"; import { StatusBadge } from "@/components/admin/StatusBadge"; -import { approveOrganizationAction, deleteOrganizationAction, updateOrganizationAction } from "../actions"; -import { ApproveOrgButton } from "./_components/ApproveOrgButton"; +import { deleteOrganizationAction, updateOrganizationAction } from "../actions"; import { DeleteOrgButton } from "./_components/DeleteOrgButton"; export const dynamic = "force-dynamic"; @@ -32,10 +31,6 @@ export default async function EditOrgPage({ params }: PageProps) { "use server"; return await deleteOrganizationAction(id); }; - const approveThis = async () => { - "use server"; - return await approveOrganizationAction(id); - }; return (
@@ -44,33 +39,12 @@ export default async function EditOrgPage({ params }: PageProps) { ← Toutes les organisations -

- {org.name} - {org.approved ? ( - - Validée - - ) : ( - - À valider - - )} -

+

{org.name}

- /{org.slug} · {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 + /{org.slug} · {org.members.length} membre{org.members.length > 1 ? "s" : ""}

- {org.contactEmail ? ( -

- Contact : {org.contactEmail} -

- ) : null} -
-
- {!org.approved ? : null} -
+
diff --git a/src/app/admin/organizations/actions.ts b/src/app/admin/organizations/actions.ts index 6a0ae6f..5f8bcf6 100644 --- a/src/app/admin/organizations/actions.ts +++ b/src/app/admin/organizations/actions.ts @@ -5,9 +5,7 @@ import { redirect } from "next/navigation"; import { z } from "zod"; import { auth } from "@/auth"; import { UserRole } from "@/generated/prisma/enums"; -import { approveOrganization as approveOrganizationLib } from "@/lib/admin/organizations"; import { requireRole } from "@/lib/authorization"; -import { sendCeApproved } from "@/lib/email"; import { prisma } from "@/lib/prisma"; import { recordAudit } from "@/lib/admin/audit"; @@ -77,38 +75,6 @@ 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(); diff --git a/src/app/admin/organizations/page.tsx b/src/app/admin/organizations/page.tsx index b6a5e95..0d394ba 100644 --- a/src/app/admin/organizations/page.tsx +++ b/src/app/admin/organizations/page.tsx @@ -1,27 +1,16 @@ import Link from "next/link"; -import { countPendingOrganizations, listOrganizationsAdmin } from "@/lib/admin/organizations"; +import { listOrganizationsAdmin } from "@/lib/admin/organizations"; export const dynamic = "force-dynamic"; type PageProps = { - searchParams: Promise<{ q?: string; status?: string }>; + searchParams: Promise<{ q?: 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 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 filters = { q: sp.q?.trim() || undefined }; + const orgs = await listOrganizationsAdmin(filters); const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short", year: "2-digit" }); return ( @@ -41,35 +30,7 @@ export default async function OrgsAdminPage({ searchParams }: PageProps) { - -
- {approved !== "all" ? ( - - ) : null} Nom - Statut Slug Membres Créée @@ -101,7 +61,7 @@ export default async function OrgsAdminPage({ searchParams }: PageProps) { {orgs.length === 0 ? ( - + Aucune organisation. @@ -116,17 +76,6 @@ export default async function OrgsAdminPage({ searchParams }: PageProps) {
{o.description.slice(0, 80)}{o.description.length > 80 ? "…" : ""}
) : null} - - {o.approved ? ( - - Validée - - ) : ( - - À valider - - )} - /{o.slug} {o.membersCount} {dateFmt.format(o.createdAt)} diff --git a/src/app/admin/payouts/_components/MarkPaidForm.tsx b/src/app/admin/payouts/_components/MarkPaidForm.tsx deleted file mode 100644 index b2129d1..0000000 --- a/src/app/admin/payouts/_components/MarkPaidForm.tsx +++ /dev/null @@ -1,126 +0,0 @@ -"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; -}; - -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(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 ( -
- - Payé {fmtEur(payout.paid.amount)} - - {payout.paid.reference ? ( - Ref : {payout.paid.reference} - ) : null} - -
- ); - } - - if (payout.netAmount <= 0) { - return ; - } - - if (!opened) { - return ( - - ); - } - - return ( - - - - {error ? {error} : null} -
- - -
- - ); -} diff --git a/src/app/admin/payouts/actions.ts b/src/app/admin/payouts/actions.ts deleted file mode 100644 index ac92fd2..0000000 --- a/src/app/admin/payouts/actions.ts +++ /dev/null @@ -1,96 +0,0 @@ -"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"); -} diff --git a/src/app/admin/payouts/page.tsx b/src/app/admin/payouts/page.tsx deleted file mode 100644 index 0c40c19..0000000 --- a/src/app/admin/payouts/page.tsx +++ /dev/null @@ -1,155 +0,0 @@ -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(); - 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 ( -
-
-

Reversements prestataires

-

- 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'apparaît pas - dans la liste (commission 0 %). -

-
- -
- - - -
- - {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 ( -
-
-

- {formatMonth(period)} -

- - Reste à payer ce mois :{" "} - {fmtEur(monthDue)} - -
- - - - - - - - - - - - - {rows - .sort((a, b) => b.netAmount - a.netAmount) - .map((p) => ( - - - - - - - - - ))} - -
PrestataireRésaCA brutCommissionNet dûStatut
- - {p.providerName} - - - {p.bookingsCount} - - {fmtEur(p.grossAmount)} - - {fmtEur(p.commission)} - - {fmtEur(p.netAmount)} - -
- -
-
-
- ); - })} -
- ); -} - -function KpiCard({ - label, - value, - highlight, -}: { - label: string; - value: string; - highlight?: boolean; -}) { - return ( -
-
{label}
-
- {value} -
-
- ); -} diff --git a/src/app/admin/rental-items/[id]/_components/ItemInlineActions.tsx b/src/app/admin/rental-items/[id]/_components/ItemInlineActions.tsx deleted file mode 100644 index 8a6a00f..0000000 --- a/src/app/admin/rental-items/[id]/_components/ItemInlineActions.tsx +++ /dev/null @@ -1,86 +0,0 @@ -"use client"; - -import { useState, useTransition } from "react"; -import { useRouter } from "next/navigation"; - -type Props = { - active: boolean; - toggleActiveAction: (active: boolean) => Promise<{ ok: true } | { ok: false; error: string } | undefined>; - deleteAction: () => Promise<{ ok: true } | { ok: false; error: string } | undefined | void>; -}; - -export function ItemInlineActions({ active, toggleActiveAction, deleteAction }: Props) { - const router = useRouter(); - const [pending, startTransition] = useTransition(); - const [confirmDelete, setConfirmDelete] = useState(false); - const [error, setError] = useState(null); - - function toggle() { - setError(null); - startTransition(async () => { - const res = await toggleActiveAction(!active); - if (res && (res as { ok?: boolean }).ok === false) setError((res as { error: string }).error); - router.refresh(); - }); - } - function del() { - setError(null); - startTransition(async () => { - const res = await deleteAction(); - if (res && (res as { ok?: boolean }).ok === false) { - setError((res as { error: string }).error); - setConfirmDelete(false); - } - }); - } - - return ( -
-
- - {confirmDelete ? ( -
- Supprimer ? - - -
- ) : ( - - )} -
- {error ?
{error}
: null} -
- ); -} diff --git a/src/app/admin/rental-items/[id]/page.tsx b/src/app/admin/rental-items/[id]/page.tsx deleted file mode 100644 index 59295d2..0000000 --- a/src/app/admin/rental-items/[id]/page.tsx +++ /dev/null @@ -1,92 +0,0 @@ -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"; -import { ItemInlineActions } from "./_components/ItemInlineActions"; -import { deleteRentalItemAction, toggleRentalItemActiveAction, updateRentalItemAction } from "../actions"; - -export const dynamic = "force-dynamic"; - -type PageProps = { params: Promise<{ id: string }> }; - -export default async function EditRentalItemPage({ params }: PageProps) { - const { id } = await params; - const [item, providers] = await Promise.all([getRentalItemForAdmin(id), listProvidersForSelect()]); - if (!item) notFound(); - - const updateThis = async (fd: FormData) => { - "use server"; - return await updateRentalItemAction(id, fd); - }; - const toggleActiveThis = async (active: boolean) => { - "use server"; - return await toggleRentalItemActiveAction(id, active); - }; - const deleteThis = async () => { - "use server"; - return await deleteRentalItemAction(id); - }; - - return ( -
-
-
- - ← Tous les items - -

- {item.name} - -

-

- {RENTAL_CATEGORY_LABEL[item.category]} ·{" "} - - {item.provider.name} - - {item.provider.isSystemD ? " (System D)" : ""} -

-
- -
- -
-

Photos & vidéos

- -
- -
- -
-
- ); -} diff --git a/src/app/admin/rental-items/_components/ItemForm.tsx b/src/app/admin/rental-items/_components/ItemForm.tsx deleted file mode 100644 index 27ad4b2..0000000 --- a/src/app/admin/rental-items/_components/ItemForm.tsx +++ /dev/null @@ -1,132 +0,0 @@ -"use client"; - -import { useState, useTransition } from "react"; -import { FormField, inputCls, selectCls, textareaCls } from "@/components/admin/FormField"; -import { RENTAL_CATEGORY_LABEL, RENTAL_CATEGORIES } from "@/lib/rental-category-labels"; - -type Props = { - providers: { id: string; name: string; isSystemD: boolean }[]; - initial?: { - providerId?: string; - 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 ItemForm({ providers, initial = {}, action, submitLabel = "Enregistrer" }: Props) { - const [pending, startTransition] = useTransition(); - const [error, setError] = useState(null); - const [success, setSuccess] = useState(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 ( -
-
-
- - - - - - - - - - -