diff --git a/package-lock.json b/package-lock.json index eb5b2bd..7d8475d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,15 +10,23 @@ "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", "bcryptjs": "^3.0.3", + "leaflet": "^1.9.4", "next": "16.2.6", "next-auth": "^5.0.0-beta.31", "pg": "^8.21.0", "react": "19.2.4", "react-dom": "19.2.4", + "react-leaflet": "^5.0.0", "resend": "^4.8.0", + "sharp": "^0.34.5", "stripe": "^18.3.0" }, "devDependencies": { @@ -506,6 +514,23 @@ "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", @@ -836,6 +861,59 @@ "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", @@ -1569,7 +1647,6 @@ "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" } @@ -2757,6 +2834,17 @@ "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" } }, + "node_modules/@react-leaflet/core": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz", + "integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==", + "license": "Hippocratic-2.1", + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.60.4", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", @@ -3609,6 +3697,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -3623,6 +3717,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/leaflet": { + "version": "1.9.21", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz", + "integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/node": { "version": "20.19.41", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz", @@ -5295,7 +5398,6 @@ "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" @@ -7597,6 +7699,12 @@ "url": "https://ko-fi.com/killymxi" } }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause" + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -9054,6 +9162,20 @@ "dev": true, "license": "MIT" }, + "node_modules/react-leaflet": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz", + "integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==", + "license": "Hippocratic-2.1", + "dependencies": { + "@react-leaflet/core": "^3.0.0" + }, + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, "node_modules/react-promise-suspense": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/react-promise-suspense/-/react-promise-suspense-0.3.4.tgz", @@ -9451,7 +9573,6 @@ "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", "hasInstallScript": true, "license": "Apache-2.0", - "optional": true, "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", @@ -9495,7 +9616,6 @@ "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 6a33258..e0a10f1 100644 --- a/package.json +++ b/package.json @@ -14,15 +14,23 @@ }, "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", "bcryptjs": "^3.0.3", + "leaflet": "^1.9.4", "next": "16.2.6", "next-auth": "^5.0.0-beta.31", "pg": "^8.21.0", "react": "19.2.4", "react-dom": "19.2.4", + "react-leaflet": "^5.0.0", "resend": "^4.8.0", + "sharp": "^0.34.5", "stripe": "^18.3.0" }, "devDependencies": { @@ -39,4 +47,4 @@ "typescript": "^5.9.3", "vitest": "^3.2.4" } -} \ No newline at end of file +} diff --git a/prisma/migrations/20260601060000_password_reset_token/migration.sql b/prisma/migrations/20260601060000_password_reset_token/migration.sql new file mode 100644 index 0000000..50033de --- /dev/null +++ b/prisma/migrations/20260601060000_password_reset_token/migration.sql @@ -0,0 +1,9 @@ +CREATE TABLE "PasswordResetToken" ( + "tokenHash" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "PasswordResetToken_pkey" PRIMARY KEY ("tokenHash") +); +CREATE INDEX "PasswordResetToken_userId_idx" ON "PasswordResetToken"("userId"); +CREATE INDEX "PasswordResetToken_expiresAt_idx" ON "PasswordResetToken"("expiresAt"); diff --git a/prisma/migrations/20260602030000_operational_criteria/migration.sql b/prisma/migrations/20260602030000_operational_criteria/migration.sql new file mode 100644 index 0000000..5bdca5f --- /dev/null +++ b/prisma/migrations/20260602030000_operational_criteria/migration.sql @@ -0,0 +1,15 @@ +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 new file mode 100644 index 0000000..8abf012 --- /dev/null +++ b/prisma/migrations/20260602100000_favorite/migration.sql @@ -0,0 +1,8 @@ +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 new file mode 100644 index 0000000..65b4eb1 --- /dev/null +++ b/prisma/migrations/20260603000000_rental_marketplace/migration.sql @@ -0,0 +1,112 @@ +-- 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 new file mode 100644 index 0000000..67a2d76 --- /dev/null +++ b/prisma/migrations/20260603100000_rental_item_media/migration.sql @@ -0,0 +1,22 @@ +-- Sprint F : RentalItemMedia (photos & vidéos pour items rental). +-- Mêmes conventions que Media (carbet) : MediaType enum existant, s3Key/s3Url, +-- sortOrder pour cover (0). Cascade sur RentalItem. + +CREATE TABLE "RentalItemMedia" ( + "id" TEXT NOT NULL, + "itemId" TEXT NOT NULL, + "type" "MediaType" NOT NULL, + "s3Key" TEXT NOT NULL, + "s3Url" TEXT NOT NULL, + "sortOrder" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "RentalItemMedia_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "RentalItemMedia_itemId_sortOrder_idx" + ON "RentalItemMedia"("itemId", "sortOrder"); + +ALTER TABLE "RentalItemMedia" + ADD CONSTRAINT "RentalItemMedia_itemId_fkey" + FOREIGN KEY ("itemId") REFERENCES "RentalItem"("id") + ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260603200000_ce_management/migration.sql b/prisma/migrations/20260603200000_ce_management/migration.sql new file mode 100644 index 0000000..eb2cc87 --- /dev/null +++ b/prisma/migrations/20260603200000_ce_management/migration.sql @@ -0,0 +1,54 @@ +-- Sprint G : CE management. +-- * Organization gagne le workflow d'approbation (approved + approvedAt + approvedBy) +-- + un contactEmail dédié pour les notifications admin. +-- * Nouveau modèle OrganizationCarbetMembership : co-gestion des carbets par les +-- CE_MANAGERs d'une org liée. Pas de unique sur carbet → un Carbet pourrait être +-- co-publié par plusieurs orgs (cas rare mais autorisé). +-- * RentalProvider gagne organizationId (nullable) : un CE peut posséder son provider. + +ALTER TABLE "Organization" + ADD COLUMN "contactEmail" TEXT, + ADD COLUMN "approved" BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN "approvedAt" TIMESTAMP(3), + ADD COLUMN "approvedBy" TEXT; + +CREATE INDEX "Organization_approved_idx" ON "Organization"("approved"); + +-- Backfill : toutes les orgs existantes sont considérées validées. +-- (Aujourd'hui : CMCK uniquement. Les futures orgs créées via signup arriveront +-- en approved=false par défaut.) +UPDATE "Organization" + SET "approved" = TRUE, + "approvedAt" = NOW() + WHERE "approved" = FALSE; + +CREATE TABLE "OrganizationCarbetMembership" ( + "organizationId" TEXT NOT NULL, + "carbetId" TEXT NOT NULL, + "addedByUserId" TEXT, + "addedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "OrganizationCarbetMembership_pkey" PRIMARY KEY ("organizationId", "carbetId") +); + +CREATE INDEX "OrganizationCarbetMembership_carbetId_idx" + ON "OrganizationCarbetMembership"("carbetId"); + +ALTER TABLE "OrganizationCarbetMembership" + ADD CONSTRAINT "OrganizationCarbetMembership_organizationId_fkey" + FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") + ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "OrganizationCarbetMembership" + ADD CONSTRAINT "OrganizationCarbetMembership_carbetId_fkey" + FOREIGN KEY ("carbetId") REFERENCES "Carbet"("id") + ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "RentalProvider" + ADD COLUMN "organizationId" TEXT; + +CREATE INDEX "RentalProvider_organizationId_idx" ON "RentalProvider"("organizationId"); + +ALTER TABLE "RentalProvider" + ADD CONSTRAINT "RentalProvider_organizationId_fkey" + FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") + ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/migrations/20260603300000_org_invite_token/migration.sql b/prisma/migrations/20260603300000_org_invite_token/migration.sql new file mode 100644 index 0000000..7ce99e5 --- /dev/null +++ b/prisma/migrations/20260603300000_org_invite_token/migration.sql @@ -0,0 +1,22 @@ +-- Sprint K : tokens d'invitation CE_MEMBER. +-- Le CE_MANAGER génère un lien /inscription?invite=TOKEN, le destinataire s'inscrit +-- automatiquement comme CE_MEMBER de l'organisation. usedAt à la consommation. + +CREATE TABLE "OrgInviteToken" ( + "tokenHash" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "email" TEXT, + "createdByUserId" TEXT, + "expiresAt" TIMESTAMP(3) NOT NULL, + "usedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "OrgInviteToken_pkey" PRIMARY KEY ("tokenHash") +); + +CREATE INDEX "OrgInviteToken_organizationId_idx" ON "OrgInviteToken"("organizationId"); +CREATE INDEX "OrgInviteToken_expiresAt_idx" ON "OrgInviteToken"("expiresAt"); + +ALTER TABLE "OrgInviteToken" + ADD CONSTRAINT "OrgInviteToken_organizationId_fkey" + FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") + ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260603400000_rental_payout_mark/migration.sql b/prisma/migrations/20260603400000_rental_payout_mark/migration.sql new file mode 100644 index 0000000..cff28de --- /dev/null +++ b/prisma/migrations/20260603400000_rental_payout_mark/migration.sql @@ -0,0 +1,28 @@ +-- Sprint O : reversements prestataires. +-- RentalPayoutMark trace les virements bancaires manuels effectués par System D +-- vers les RentalProvider tiers (le marketplace encaisse centralisé, redistribue +-- hors plateforme une fois par mois). Unique (provider, mois) pour empêcher +-- les marquages en doublon. + +CREATE TABLE "RentalPayoutMark" ( + "id" TEXT NOT NULL, + "providerId" TEXT NOT NULL, + "periodMonth" TIMESTAMP(3) NOT NULL, + "amount" DECIMAL(10, 2) NOT NULL, + "reference" TEXT, + "paidAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "paidByEmail" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "RentalPayoutMark_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "RentalPayoutMark_providerId_periodMonth_key" + ON "RentalPayoutMark"("providerId", "periodMonth"); + +CREATE INDEX "RentalPayoutMark_periodMonth_idx" + ON "RentalPayoutMark"("periodMonth"); + +ALTER TABLE "RentalPayoutMark" + ADD CONSTRAINT "RentalPayoutMark_providerId_fkey" + FOREIGN KEY ("providerId") REFERENCES "RentalProvider"("id") + ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e9aaf6d..6ae7e3f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -13,6 +13,7 @@ enum UserRole { CE_MEMBER TOURIST ADMIN + RENTAL_PROVIDER } enum CarbetStatus { @@ -71,16 +72,59 @@ enum TransportMode { } model Organization { - id String @id @default(cuid()) - name String - slug String @unique - description String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + name String + slug String @unique + description String? + contactEmail String? + approved Boolean @default(false) + approvedAt DateTime? + approvedBy String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt - members User[] + members User[] + carbetMemberships OrganizationCarbetMembership[] + rentalProviders RentalProvider[] + invites OrgInviteToken[] @@index([name]) + @@index([approved]) +} + +/// Token d'invitation pour rejoindre une organisation comme CE_MEMBER. +/// Le CE_MANAGER génère un lien, le destinataire s'inscrit via /inscription?invite=TOKEN. +/// Pas de unique sur email pour permettre plusieurs invites pendants par destinataire. +model OrgInviteToken { + tokenHash String @id + organizationId String + email String? + createdByUserId String? + expiresAt DateTime + usedAt DateTime? + createdAt DateTime @default(now()) + + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + + @@index([organizationId]) + @@index([expiresAt]) +} + +/// Co-gestion des carbets côté CE. Un Carbet a toujours un ownerId (créateur initial), +/// et zéro ou plusieurs orgs liées : un CE_MANAGER d'une org liée peut gérer le carbet +/// en plus de l'owner. Pour un hôte individuel : aucune membership ; pour un carbet CE : +/// 1 membership pour l'org du créateur. Plusieurs orgs possibles si co-publication. +model OrganizationCarbetMembership { + organizationId String + carbetId String + addedByUserId String? + addedAt DateTime @default(now()) + + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + carbet Carbet @relation(fields: [carbetId], references: [id], onDelete: Cascade) + + @@id([organizationId, carbetId]) + @@index([carbetId]) } model User { @@ -97,11 +141,13 @@ 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[] + 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") @@index([organizationId]) @@index([role]) @@ -124,6 +170,11 @@ 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. @@ -149,6 +200,7 @@ model Carbet { bookings Booking[] reviews Review[] subscriptions Subscription[] + organizations OrganizationCarbetMembership[] @@index([ownerId]) @@index([status]) @@ -244,7 +296,8 @@ model Booking { carbet Carbet @relation(fields: [carbetId], references: [id], onDelete: Restrict) tenant User @relation("BookingTenant", fields: [tenantId], references: [id], onDelete: Restrict) - review Review? + review Review? + rentalBookings RentalBooking[] @@index([carbetId]) @@index([tenantId]) @@ -361,3 +414,206 @@ model Translation { @@id([key, lang]) @@index([lang]) } + +model PasswordResetToken { + tokenHash String @id + userId String + expiresAt DateTime + createdAt DateTime @default(now()) + + @@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 new file mode 100644 index 0000000..a185b67 Binary files /dev/null and b/public/icons/apple-touch-icon.png differ diff --git a/public/icons/favicon-32.png b/public/icons/favicon-32.png new file mode 100644 index 0000000..c062acf Binary files /dev/null and b/public/icons/favicon-32.png differ diff --git a/public/icons/icon-192-maskable.png b/public/icons/icon-192-maskable.png new file mode 100644 index 0000000..e80f811 Binary files /dev/null and b/public/icons/icon-192-maskable.png differ diff --git a/public/icons/icon-192.png b/public/icons/icon-192.png new file mode 100644 index 0000000..cb0fd13 Binary files /dev/null and b/public/icons/icon-192.png differ diff --git a/public/icons/icon-512-maskable.png b/public/icons/icon-512-maskable.png new file mode 100644 index 0000000..5041e00 Binary files /dev/null and b/public/icons/icon-512-maskable.png differ diff --git a/public/icons/icon-512.png b/public/icons/icon-512.png new file mode 100644 index 0000000..abb04bf Binary files /dev/null and b/public/icons/icon-512.png differ diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest new file mode 100644 index 0000000..2f32e8d --- /dev/null +++ b/public/manifest.webmanifest @@ -0,0 +1,60 @@ +{ + "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 new file mode 100755 index 0000000..abe63d4 --- /dev/null +++ b/scripts/backup-postgres.sh @@ -0,0 +1,54 @@ +#!/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 new file mode 100644 index 0000000..513e1ac --- /dev/null +++ b/src/app/accueil/page.tsx @@ -0,0 +1,60 @@ +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 new file mode 100644 index 0000000..0ec2427 --- /dev/null +++ b/src/app/admin/analytics/page.tsx @@ -0,0 +1,169 @@ +import Link from "next/link"; + +import { MonthlyRevenueChart } from "@/components/analytics/MonthlyRevenueChart"; +import { getAdminGlobalKpis, getMonthlyRevenueSeries } from "@/lib/analytics"; + +export const dynamic = "force-dynamic"; +export const metadata = { title: "Analytics globaux — Karbé admin" }; + +const ROLE_LABEL: Record = { + 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 new file mode 100644 index 0000000..95bfc88 --- /dev/null +++ b/src/app/admin/carbets/[id]/_components/CarbetMemberships.tsx @@ -0,0 +1,125 @@ +"use client"; + +import { useState, useTransition } from "react"; + +type Org = { id: string; name: string; slug: string; approved: boolean }; +type LinkedOrg = Org & { addedAt: Date }; + +type Props = { + carbetId: string; + linked: LinkedOrg[]; + available: Org[]; + linkAction: (carbetId: string, orgId: string) => Promise<{ ok: true; alreadyLinked: boolean } | { ok: false; error?: string }>; + unlinkAction: (carbetId: string, orgId: string) => Promise<{ ok: true } | { ok: false; error?: string }>; +}; + +export function CarbetMemberships({ + carbetId, + linked, + available, + linkAction, + unlinkAction, +}: Props) { + const [pending, startTransition] = useTransition(); + const [selectedOrgId, setSelectedOrgId] = useState(""); + const [error, setError] = useState(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 02c0d80..6a69e2e 100644 --- a/src/app/admin/carbets/[id]/page.tsx +++ b/src/app/admin/carbets/[id]/page.tsx @@ -1,15 +1,23 @@ import { notFound } from "next/navigation"; import Link from "next/link"; + +import { MediaUploader } from "@/components/MediaUploader"; +import { StatusBadge } from "@/components/admin/StatusBadge"; import { getCarbetForEdit, + listOrganizationsForLink, listOwners, listPirogueProviders, } from "@/lib/admin/carbets"; + import { CarbetForm } from "../_components/CarbetForm"; -import { StatusBadge } from "@/components/admin/StatusBadge"; -import { MediaManager } from "./_components/MediaManager"; +import { + linkCarbetToOrganizationAction, + unlinkCarbetFromOrganizationAction, + updateCarbetAction, +} from "../actions"; +import { CarbetMemberships } from "./_components/CarbetMemberships"; import { StatusActions } from "./_components/StatusActions"; -import { updateCarbetAction } from "../actions"; export const dynamic = "force-dynamic"; @@ -17,10 +25,11 @@ type PageProps = { params: Promise<{ id: string }> }; export default async function EditCarbetPage({ params }: PageProps) { const { id } = await params; - const [carbet, owners, providers] = await Promise.all([ + const [carbet, owners, providers, organizations] = await Promise.all([ getCarbetForEdit(id), listOwners(), listPirogueProviders(), + listOrganizationsForLink(), ]); if (!carbet) notFound(); @@ -28,6 +37,14 @@ export default async function EditCarbetPage({ params }: PageProps) { "use server"; return await updateCarbetAction(id, fd); }; + const linkThis = async (carbetId: string, orgId: string) => { + "use server"; + return await linkCarbetToOrganizationAction(carbetId, orgId); + }; + const unlinkThis = async (carbetId: string, orgId: string) => { + "use server"; + return await unlinkCarbetFromOrganizationAction(carbetId, orgId); + }; return (
@@ -61,16 +78,40 @@ export default async function EditCarbetPage({ params }: PageProps) {
- ({ - id: m.id, - type: m.type, - s3Key: m.s3Key, - s3Url: m.s3Url, - sortOrder: m.sortOrder, - }))} - /> +
+

+ 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, + }))} + /> +
+ {/* 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 9e2fbff..f85950a 100644 --- a/src/app/admin/carbets/actions.ts +++ b/src/app/admin/carbets/actions.ts @@ -10,7 +10,9 @@ import { prisma } from "@/lib/prisma"; import { AccessType, CarbetStatus, + Electricity, MediaType, + RoadAccess, TransportMode, UserRole, } from "@/generated/prisma/enums"; @@ -29,6 +31,16 @@ 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(), @@ -53,9 +65,11 @@ function parseFromFormData(fd: FormData) { if (typeof v === "string") obj[k] = v; } // Normalise les champs optionnels nullables - ["roadAccessNote", "pirogueDurationMin", "minStayNights", "maxStayNights", "minCapacity", "transportMode", "pirogueProviderId"].forEach( + ["roadAccessNote", "pirogueDurationMin", "minStayNights", "maxStayNights", "minCapacity", "transportMode", "pirogueProviderId", "roadAccess", "electricity", "gsmExitDistanceKm"].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; } @@ -199,6 +213,42 @@ export async function reorderMediaAction(carbetId: string, mediaId: string, dire return { ok: true as const }; } +export async function linkCarbetToOrganizationAction(carbetId: string, organizationId: string) { + await requireRole([UserRole.ADMIN]); + const session = await auth(); + const actorEmail = session?.user?.email ?? null; + // findFirst pour idempotence : si déjà lié, on ne touche pas + on ne crash pas. + const existing = await prisma.organizationCarbetMembership.findUnique({ + where: { organizationId_carbetId: { organizationId, carbetId } }, + select: { organizationId: true }, + }); + if (existing) { + return { ok: true as const, alreadyLinked: true }; + } + await prisma.organizationCarbetMembership.create({ + data: { + organizationId, + carbetId, + addedByUserId: session?.user?.id ?? null, + }, + }); + await audit("carbet.org.link", carbetId, actorEmail, { organizationId }); + revalidatePath(`/admin/carbets/${carbetId}`); + return { ok: true as const, alreadyLinked: false }; +} + +export async function unlinkCarbetFromOrganizationAction(carbetId: string, organizationId: string) { + await requireRole([UserRole.ADMIN]); + const session = await auth(); + const actorEmail = session?.user?.email ?? null; + await prisma.organizationCarbetMembership + .delete({ where: { organizationId_carbetId: { organizationId, carbetId } } }) + .catch(() => {}); + await audit("carbet.org.unlink", carbetId, actorEmail, { organizationId }); + revalidatePath(`/admin/carbets/${carbetId}`); + return { ok: true as const }; +} + async function audit( event: string, entityId: string, diff --git a/src/app/admin/organizations/[id]/_components/ApproveOrgButton.tsx b/src/app/admin/organizations/[id]/_components/ApproveOrgButton.tsx new file mode 100644 index 0000000..d53a21c --- /dev/null +++ b/src/app/admin/organizations/[id]/_components/ApproveOrgButton.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { useState, useTransition } from "react"; + +type Props = { + action: () => Promise<{ ok: false; error: string } | { ok: true } | undefined | void>; +}; + +export function ApproveOrgButton({ action }: Props) { + const [pending, startTransition] = useTransition(); + const [error, setError] = useState(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 90c91b6..810ba23 100644 --- a/src/app/admin/organizations/[id]/page.tsx +++ b/src/app/admin/organizations/[id]/page.tsx @@ -3,7 +3,8 @@ import Link from "next/link"; import { getOrganizationForAdmin } from "@/lib/admin/organizations"; import { OrgForm } from "../_components/OrgForm"; import { StatusBadge } from "@/components/admin/StatusBadge"; -import { deleteOrganizationAction, updateOrganizationAction } from "../actions"; +import { approveOrganizationAction, deleteOrganizationAction, updateOrganizationAction } from "../actions"; +import { ApproveOrgButton } from "./_components/ApproveOrgButton"; import { DeleteOrgButton } from "./_components/DeleteOrgButton"; export const dynamic = "force-dynamic"; @@ -31,6 +32,10 @@ export default async function EditOrgPage({ params }: PageProps) { "use server"; return await deleteOrganizationAction(id); }; + const approveThis = async () => { + "use server"; + return await approveOrganizationAction(id); + }; return (
@@ -39,12 +44,33 @@ export default async function EditOrgPage({ params }: PageProps) { ← Toutes les organisations -

{org.name}

+

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

- /{org.slug} · {org.members.length} membre{org.members.length > 1 ? "s" : ""} + /{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.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 5f8bcf6..6a0ae6f 100644 --- a/src/app/admin/organizations/actions.ts +++ b/src/app/admin/organizations/actions.ts @@ -5,7 +5,9 @@ import { redirect } from "next/navigation"; import { z } from "zod"; import { auth } from "@/auth"; import { UserRole } from "@/generated/prisma/enums"; +import { approveOrganization as approveOrganizationLib } from "@/lib/admin/organizations"; import { requireRole } from "@/lib/authorization"; +import { sendCeApproved } from "@/lib/email"; import { prisma } from "@/lib/prisma"; import { recordAudit } from "@/lib/admin/audit"; @@ -75,6 +77,38 @@ export async function updateOrganizationAction(id: string, fd: FormData) { return { ok: true as const }; } +export async function approveOrganizationAction(id: string) { + await requireRole([UserRole.ADMIN]); + const session = await auth(); + const actor = session?.user?.email ?? null; + const res = await approveOrganizationLib(id, actor ?? "admin"); + if (!res.ok) return res; + if (!res.alreadyApproved) { + await audit("organization.approve", id, actor, {}); + // Notifier les CE_MANAGERs de l'org : leur compte vient d'être débloqué. + try { + const data = await prisma.organization.findUnique({ + where: { id }, + select: { + name: true, + members: { + where: { role: UserRole.CE_MANAGER, isActive: true }, + select: { email: true, firstName: true }, + }, + }, + }); + for (const m of data?.members ?? []) { + await sendCeApproved(m.email, m.firstName, data?.name ?? ""); + } + } catch (e) { + console.error("[admin.org.approve] email send failed:", e instanceof Error ? e.message : e); + } + } + revalidatePath("/admin/organizations"); + revalidatePath(`/admin/organizations/${id}`); + return { ok: true as const }; +} + export async function deleteOrganizationAction(id: string) { await requireRole([UserRole.ADMIN]); const session = await auth(); diff --git a/src/app/admin/organizations/page.tsx b/src/app/admin/organizations/page.tsx index 0d394ba..b6a5e95 100644 --- a/src/app/admin/organizations/page.tsx +++ b/src/app/admin/organizations/page.tsx @@ -1,16 +1,27 @@ import Link from "next/link"; -import { listOrganizationsAdmin } from "@/lib/admin/organizations"; +import { countPendingOrganizations, listOrganizationsAdmin } from "@/lib/admin/organizations"; export const dynamic = "force-dynamic"; type PageProps = { - searchParams: Promise<{ q?: string }>; + searchParams: Promise<{ q?: string; status?: string }>; }; +const STATUS_VALUES = ["all", "pending", "approved"] as const; +type StatusFilter = (typeof STATUS_VALUES)[number]; + +function isStatusFilter(s: string | undefined): s is StatusFilter { + return STATUS_VALUES.includes(s as StatusFilter); +} + export default async function OrgsAdminPage({ searchParams }: PageProps) { const sp = await searchParams; - const filters = { q: sp.q?.trim() || undefined }; - const orgs = await listOrganizationsAdmin(filters); + const approved = isStatusFilter(sp.status) ? sp.status : "all"; + const filters = { q: sp.q?.trim() || undefined, approved }; + const [orgs, pendingCount] = await Promise.all([ + listOrganizationsAdmin(filters), + countPendingOrganizations(), + ]); const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short", year: "2-digit" }); return ( @@ -30,7 +41,35 @@ export default async function OrgsAdminPage({ searchParams }: PageProps) { + +
+ {approved !== "all" ? ( + + ) : null} Nom + Statut Slug Membres Créée @@ -61,7 +101,7 @@ export default async function OrgsAdminPage({ searchParams }: PageProps) { {orgs.length === 0 ? ( - + Aucune organisation. @@ -76,6 +116,17 @@ 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 new file mode 100644 index 0000000..b2129d1 --- /dev/null +++ b/src/app/admin/payouts/_components/MarkPaidForm.tsx @@ -0,0 +1,126 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; + +import type { ProviderPayout } from "@/lib/payouts"; + +type Props = { + payout: ProviderPayout; + markAction: ( + providerId: string, + periodMonthISO: string, + fd: FormData, + ) => Promise<{ ok: false; error: string } | { ok: true; alreadyExists: boolean }>; + unmarkAction: (providerId: string, periodMonthISO: string) => Promise; +}; + +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 new file mode 100644 index 0000000..ac92fd2 --- /dev/null +++ b/src/app/admin/payouts/actions.ts @@ -0,0 +1,96 @@ +"use server"; + +import { revalidatePath } from "next/cache"; + +import { auth } from "@/auth"; +import { UserRole } from "@/generated/prisma/enums"; +import { recordAudit } from "@/lib/admin/audit"; +import { requireRole } from "@/lib/authorization"; +import { + createPayoutMark, + deletePayoutMark, +} from "@/lib/payouts"; +import { prisma } from "@/lib/prisma"; +import { sendPayoutSent } from "@/lib/email"; + +export async function markPayoutPaidAction( + providerId: string, + periodMonthISO: string, + fd: FormData, +): Promise<{ ok: false; error: string } | { ok: true; alreadyExists: boolean }> { + await requireRole([UserRole.ADMIN]); + const session = await auth(); + const actor = session?.user?.email ?? null; + const amount = Number(fd.get("amount") ?? 0); + const reference = ((fd.get("reference") as string | null) ?? "").trim() || null; + + if (!Number.isFinite(amount) || amount < 0) { + return { ok: false, error: "Montant invalide." }; + } + const periodMonth = new Date(periodMonthISO); + if (Number.isNaN(periodMonth.getTime())) { + return { ok: false, error: "Période invalide." }; + } + + const res = await createPayoutMark({ + providerId, + periodMonth, + amount, + reference, + paidByEmail: actor, + }); + if (!res.ok) return res; + + await recordAudit({ + scope: "admin.payouts", + event: res.alreadyExists ? "payout.already_marked" : "payout.mark", + target: providerId, + actorEmail: actor, + details: { + periodMonth: periodMonth.toISOString().slice(0, 7), + amount, + reference, + }, + }); + + // Notif provider best-effort (n'envoie que si on a un contactEmail) + if (!res.alreadyExists) { + try { + const provider = await prisma.rentalProvider.findUnique({ + where: { id: providerId }, + select: { name: true, contactEmail: true }, + }); + if (provider?.contactEmail) { + await sendPayoutSent( + provider.contactEmail, + provider.name, + periodMonth, + amount.toFixed(2), + reference, + ); + } + } catch (e) { + console.error("[payouts] email send failed:", e instanceof Error ? e.message : e); + } + } + + revalidatePath("/admin/payouts"); + return { ok: true, alreadyExists: res.alreadyExists }; +} + +export async function unmarkPayoutPaidAction(providerId: string, periodMonthISO: string) { + await requireRole([UserRole.ADMIN]); + const session = await auth(); + const actor = session?.user?.email ?? null; + const periodMonth = new Date(periodMonthISO); + if (Number.isNaN(periodMonth.getTime())) return; + await deletePayoutMark(providerId, periodMonth); + await recordAudit({ + scope: "admin.payouts", + event: "payout.unmark", + target: providerId, + actorEmail: actor, + details: { periodMonth: periodMonth.toISOString().slice(0, 7) }, + }); + revalidatePath("/admin/payouts"); +} diff --git a/src/app/admin/payouts/page.tsx b/src/app/admin/payouts/page.tsx new file mode 100644 index 0000000..0c40c19 --- /dev/null +++ b/src/app/admin/payouts/page.tsx @@ -0,0 +1,155 @@ +import Link from "next/link"; + +import { formatMonth, listProviderPayouts } from "@/lib/payouts"; + +import { markPayoutPaidAction, unmarkPayoutPaidAction } from "./actions"; +import { MarkPaidForm } from "./_components/MarkPaidForm"; + +export const dynamic = "force-dynamic"; +export const metadata = { title: "Reversements prestataires — Karbé admin" }; + +function fmtEur(n: number): string { + return n.toLocaleString("fr-FR", { style: "currency", currency: "EUR" }); +} + +export default async function PayoutsAdminPage() { + const payouts = await listProviderPayouts({ monthsBack: 6 }); + + // Group by month + const byMonth = new Map(); + 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 new file mode 100644 index 0000000..8a6a00f --- /dev/null +++ b/src/app/admin/rental-items/[id]/_components/ItemInlineActions.tsx @@ -0,0 +1,86 @@ +"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 new file mode 100644 index 0000000..59295d2 --- /dev/null +++ b/src/app/admin/rental-items/[id]/page.tsx @@ -0,0 +1,92 @@ +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 new file mode 100644 index 0000000..27ad4b2 --- /dev/null +++ b/src/app/admin/rental-items/_components/ItemForm.tsx @@ -0,0 +1,132 @@ +"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 ( +
+
+
+ + + + + + + + + + +