diff --git a/package-lock.json b/package-lock.json index 7d8475d..c1f89be 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,10 +10,6 @@ "hasInstallScript": true, "dependencies": { "@aws-sdk/client-s3": "^3.1056.0", - "@aws-sdk/s3-request-presigner": "^3.1058.0", - "@dnd-kit/core": "^6.3.1", - "@dnd-kit/sortable": "^8.0.0", - "@dnd-kit/utilities": "^3.2.2", "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", "@types/leaflet": "^1.9.21", @@ -26,7 +22,6 @@ "react-dom": "19.2.4", "react-leaflet": "^5.0.0", "resend": "^4.8.0", - "sharp": "^0.34.5", "stripe": "^18.3.0" }, "devDependencies": { @@ -514,23 +509,6 @@ "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/s3-request-presigner": { - "version": "3.1058.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.1058.0.tgz", - "integrity": "sha512-IRgNfn8U3zfsZ0JkpmwjS59R/XyHMHxpuwW6HVuJhik+FsbClhNkujEO0w1WqJvXrF4FX+7qIAwUrvlwNvaZ7Q==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.15", - "@aws-sdk/signature-v4-multi-region": "^3.996.30", - "@aws-sdk/types": "^3.973.9", - "@smithy/core": "^3.24.5", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, "node_modules/@aws-sdk/signature-v4-multi-region": { "version": "3.996.30", "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.30.tgz", @@ -861,59 +839,6 @@ "node": ">=18" } }, - "node_modules/@dnd-kit/accessibility": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", - "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", - "license": "MIT", - "dependencies": { - "tslib": "^2.0.0" - }, - "peerDependencies": { - "react": ">=16.8.0" - } - }, - "node_modules/@dnd-kit/core": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", - "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", - "license": "MIT", - "dependencies": { - "@dnd-kit/accessibility": "^3.1.1", - "@dnd-kit/utilities": "^3.2.2", - "tslib": "^2.0.0" - }, - "peerDependencies": { - "react": ">=16.8.0", - "react-dom": ">=16.8.0" - } - }, - "node_modules/@dnd-kit/sortable": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-8.0.0.tgz", - "integrity": "sha512-U3jk5ebVXe1Lr7c2wU7SBZjcWdQP+j7peHJfCspnA81enlu88Mgd7CC8Q+pub9ubP7eKVETzJW+IBAhsqbSu/g==", - "license": "MIT", - "dependencies": { - "@dnd-kit/utilities": "^3.2.2", - "tslib": "^2.0.0" - }, - "peerDependencies": { - "@dnd-kit/core": "^6.1.0", - "react": ">=16.8.0" - } - }, - "node_modules/@dnd-kit/utilities": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", - "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", - "license": "MIT", - "dependencies": { - "tslib": "^2.0.0" - }, - "peerDependencies": { - "react": ">=16.8.0" - } - }, "node_modules/@electric-sql/pglite": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.1.tgz", @@ -1647,6 +1572,7 @@ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", "license": "MIT", + "optional": true, "engines": { "node": ">=18" } @@ -5398,6 +5324,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -9573,6 +9500,7 @@ "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", "hasInstallScript": true, "license": "Apache-2.0", + "optional": true, "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", @@ -9616,6 +9544,7 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", "license": "ISC", + "optional": true, "bin": { "semver": "bin/semver.js" }, diff --git a/package.json b/package.json index e0a10f1..000a852 100644 --- a/package.json +++ b/package.json @@ -14,10 +14,6 @@ }, "dependencies": { "@aws-sdk/client-s3": "^3.1056.0", - "@aws-sdk/s3-request-presigner": "^3.1058.0", - "@dnd-kit/core": "^6.3.1", - "@dnd-kit/sortable": "^8.0.0", - "@dnd-kit/utilities": "^3.2.2", "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", "@types/leaflet": "^1.9.21", @@ -30,7 +26,6 @@ "react-dom": "19.2.4", "react-leaflet": "^5.0.0", "resend": "^4.8.0", - "sharp": "^0.34.5", "stripe": "^18.3.0" }, "devDependencies": { 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/20260602100000_favorite/migration.sql b/prisma/migrations/20260602100000_favorite/migration.sql deleted file mode 100644 index 8abf012..0000000 --- a/prisma/migrations/20260602100000_favorite/migration.sql +++ /dev/null @@ -1,8 +0,0 @@ -CREATE TABLE "Favorite" ( - "userId" TEXT NOT NULL, - "carbetId" TEXT NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT "Favorite_pkey" PRIMARY KEY ("userId", "carbetId") -); -CREATE INDEX "Favorite_userId_idx" ON "Favorite"("userId"); -CREATE INDEX "Favorite_carbetId_idx" ON "Favorite"("carbetId"); 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..f59864e 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]) @@ -424,196 +371,3 @@ model PasswordResetToken { @@index([userId]) @@index([expiresAt]) } - -model Favorite { - userId String - carbetId String - createdAt DateTime @default(now()) - - @@id([userId, carbetId]) - @@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/public/icons/apple-touch-icon.png b/public/icons/apple-touch-icon.png deleted file mode 100644 index a185b67..0000000 Binary files a/public/icons/apple-touch-icon.png and /dev/null differ diff --git a/public/icons/favicon-32.png b/public/icons/favicon-32.png deleted file mode 100644 index c062acf..0000000 Binary files a/public/icons/favicon-32.png and /dev/null differ diff --git a/public/icons/icon-192-maskable.png b/public/icons/icon-192-maskable.png deleted file mode 100644 index e80f811..0000000 Binary files a/public/icons/icon-192-maskable.png and /dev/null differ diff --git a/public/icons/icon-192.png b/public/icons/icon-192.png deleted file mode 100644 index cb0fd13..0000000 Binary files a/public/icons/icon-192.png and /dev/null differ diff --git a/public/icons/icon-512-maskable.png b/public/icons/icon-512-maskable.png deleted file mode 100644 index 5041e00..0000000 Binary files a/public/icons/icon-512-maskable.png and /dev/null differ diff --git a/public/icons/icon-512.png b/public/icons/icon-512.png deleted file mode 100644 index abb04bf..0000000 Binary files a/public/icons/icon-512.png and /dev/null differ diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest deleted file mode 100644 index 2f32e8d..0000000 --- a/public/manifest.webmanifest +++ /dev/null @@ -1,60 +0,0 @@ -{ - "name": "Karbé — carbets fluviaux de Guyane", - "short_name": "Karbé", - "description": "Au fil de l'eau : louez des carbets le long des fleuves de Guyane.", - "start_url": "/decouvrir", - "id": "/decouvrir", - "scope": "/", - "display": "standalone", - "orientation": "portrait", - "background_color": "#000000", - "theme_color": "#059669", - "lang": "fr", - "categories": ["travel", "lifestyle"], - "icons": [ - { - "src": "/icons/icon-192.png", - "sizes": "192x192", - "type": "image/png", - "purpose": "any" - }, - { - "src": "/icons/icon-512.png", - "sizes": "512x512", - "type": "image/png", - "purpose": "any" - }, - { - "src": "/icons/icon-192-maskable.png", - "sizes": "192x192", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "/icons/icon-512-maskable.png", - "sizes": "512x512", - "type": "image/png", - "purpose": "maskable" - } - ], - "shortcuts": [ - { - "name": "Au fil de l'eau", - "short_name": "Découvrir", - "url": "/decouvrir", - "icons": [{ "src": "/icons/icon-192.png", "sizes": "192x192" }] - }, - { - "name": "Mes favoris", - "short_name": "Favoris", - "url": "/mes-favoris", - "icons": [{ "src": "/icons/icon-192.png", "sizes": "192x192" }] - }, - { - "name": "Mon compte", - "short_name": "Compte", - "url": "/mon-compte", - "icons": [{ "src": "/icons/icon-192.png", "sizes": "192x192" }] - } - ] -} diff --git a/scripts/backup-postgres.sh b/scripts/backup-postgres.sh deleted file mode 100755 index abe63d4..0000000 --- a/scripts/backup-postgres.sh +++ /dev/null @@ -1,54 +0,0 @@ -#!/bin/bash -# -# Backup nightly du PostgreSQL Karbé vers MinIO. -# Lancé par un systemd timer (karbe-backup.timer). -# -# Rétention 30 jours côté MinIO (s'appuyer sur une lifecycle policy ou un -# nettoyage côté `mc rm` planifié — TODO si on veut être propre). - -set -euo pipefail - -STAMP=$(date -u +%Y%m%d-%H%M%S) -DUMP_DIR=/tmp/karbe-backup -DUMP_FILE="$DUMP_DIR/karbe-${STAMP}.sql.gz" -BUCKET_DEST="karbe-backups/postgres/karbe-${STAMP}.sql.gz" - -mkdir -p "$DUMP_DIR" - -# Dump compressé depuis le conteneur postgres -docker compose -f /home/ubuntu/karbe/docker-compose.prod.yml \ - -f /home/ubuntu/karbe/docker-compose.override.yml \ - exec -T postgres pg_dump -U karbe -d karbe \ - | gzip > "$DUMP_FILE" - -SIZE=$(stat -c %s "$DUMP_FILE") -echo "[$(date -u +%FT%TZ)] dump created size=${SIZE}B path=${DUMP_FILE}" - -# Push vers MinIO via mc Docker -docker run --rm --network karbe-net \ - --entrypoint /bin/sh \ - -v "$DUMP_DIR:/dump" \ - -e MINIO_ROOT_USER \ - -e MINIO_ROOT_PASSWORD \ - minio/mc:latest -c " - mc alias set karbe http://minio:9000 \"\$MINIO_ROOT_USER\" \"\$MINIO_ROOT_PASSWORD\" >/dev/null 2>&1 && \ - mc mb karbe/karbe-backups --ignore-existing >/dev/null 2>&1 && \ - mc cp /dump/karbe-${STAMP}.sql.gz karbe/${BUCKET_DEST} - " - -echo "[$(date -u +%FT%TZ)] uploaded to karbe/${BUCKET_DEST}" - -# Nettoyage local -rm -f "$DUMP_FILE" - -# Rétention : supprime les backups > 30 jours dans MinIO -docker run --rm --network karbe-net \ - --entrypoint /bin/sh \ - -e MINIO_ROOT_USER \ - -e MINIO_ROOT_PASSWORD \ - minio/mc:latest -c " - mc alias set karbe http://minio:9000 \"\$MINIO_ROOT_USER\" \"\$MINIO_ROOT_PASSWORD\" >/dev/null 2>&1 && \ - mc rm --recursive --force --older-than 30d karbe/karbe-backups/ 2>/dev/null || true - " - -echo "[$(date -u +%FT%TZ)] retention sweep done (>30d removed)" diff --git a/src/app/accueil/page.tsx b/src/app/accueil/page.tsx deleted file mode 100644 index 513e1ac..0000000 --- a/src/app/accueil/page.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import Link from "next/link"; -import { IfPluginEnabled } from "@/components/IfPluginEnabled"; -import { HeroSection } from "@/components/landing/HeroSection"; -import { ExperiencesSection } from "@/components/landing/ExperiencesSection"; -import { HowItWorksSection } from "@/components/landing/HowItWorksSection"; -import { CESection } from "@/components/landing/CESection"; -import { TestimonialsSection } from "@/components/landing/TestimonialsSection"; -import { LandingFooter } from "@/components/landing/Footer"; - -export const metadata = { title: "Accueil — Karbé" }; - -/** - * Landing « marketing » historique (hero + sections + footer riche). Conservée - * à /accueil après la promotion de /decouvrir comme nouvelle page d'index. - */ -export default function LandingPage() { - return ( - <> - -
-

- Karbé — carbets fluviaux de Guyane -

-

- La marketplace pour louer des carbets le long des fleuves de Guyane. -

-
- - Au fil de l'eau - - - Catalogue - -
-
- - } - > - -
- - - - - - - - - - ); -} 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..02c0d80 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 { MediaManager } from "./_components/MediaManager"; 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,40 +61,16 @@ 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 -

- ({ - id: m.id, - type: m.type, - s3Key: m.s3Key, - s3Url: m.s3Url, - sortOrder: m.sortOrder, - }))} - /> -
+ ({ + id: m.id, + type: m.type, + s3Key: m.s3Key, + s3Url: m.s3Url, + sortOrder: m.sortOrder, + }))} + /> - {/* 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 ( -
-
-
- - - - - - - - - - -