Compare commits
No commits in common. "main" and "feat/admin-sprint3-activity" have entirely different histories.
main
...
feat/admin
249 changed files with 327 additions and 24179 deletions
|
|
@ -1,59 +0,0 @@
|
||||||
name: CI
|
|
||||||
|
|
||||||
# Lance lint + typecheck + tests + build sur push/PR.
|
|
||||||
#
|
|
||||||
# Workflow dormant tant qu'aucun runner Forgejo n'est enregistré.
|
|
||||||
# Pour activer :
|
|
||||||
# 1) Sur git.cosmolan.fr, générer un token runner :
|
|
||||||
# Admin → Actions → Runners → Create new Runner Token
|
|
||||||
# (ou pour ce repo seul : Settings → Actions → Runners → Create)
|
|
||||||
# 2) Sur la machine d'exécution :
|
|
||||||
# wget https://codeberg.org/forgejo/runner/releases/download/v6.7.0/forgejo-runner-6.7.0-linux-amd64
|
|
||||||
# chmod +x forgejo-runner-6.7.0-linux-amd64
|
|
||||||
# ./forgejo-runner-6.7.0-linux-amd64 register \
|
|
||||||
# --instance https://git.cosmolan.fr \
|
|
||||||
# --token <TOKEN> \
|
|
||||||
# --name karbe-ci \
|
|
||||||
# --labels "ubuntu-latest:docker://node:20"
|
|
||||||
# 3) Démarrer :
|
|
||||||
# ./forgejo-runner-6.7.0-linux-amd64 daemon
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
pull_request:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "20"
|
|
||||||
cache: "npm"
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm ci --no-audit --no-fund
|
|
||||||
|
|
||||||
- name: Generate Prisma client
|
|
||||||
run: npx prisma generate
|
|
||||||
|
|
||||||
- name: Lint
|
|
||||||
run: npm run lint
|
|
||||||
|
|
||||||
- name: Typecheck
|
|
||||||
run: npm run typecheck
|
|
||||||
|
|
||||||
- name: Test
|
|
||||||
run: npm test
|
|
||||||
|
|
||||||
- name: Build (smoke)
|
|
||||||
run: npm run build
|
|
||||||
env:
|
|
||||||
# Stubs nécessaires au build statique — pas de connexion réelle.
|
|
||||||
DATABASE_URL: "postgresql://stub:stub@localhost:5432/stub?schema=public"
|
|
||||||
NEXTAUTH_SECRET: "ci-secret-not-for-production"
|
|
||||||
AUTH_SECRET: "ci-secret-not-for-production"
|
|
||||||
NEXT_PUBLIC_SITE_URL: "https://example.invalid"
|
|
||||||
2460
package-lock.json
generated
2460
package-lock.json
generated
File diff suppressed because it is too large
Load diff
18
package.json
18
package.json
|
|
@ -7,30 +7,18 @@
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
"postinstall": "prisma generate",
|
"postinstall": "prisma generate"
|
||||||
"test": "vitest run",
|
|
||||||
"test:watch": "vitest",
|
|
||||||
"typecheck": "tsc --noEmit"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.1056.0",
|
"@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/adapter-pg": "^7.8.0",
|
||||||
"@prisma/client": "^7.8.0",
|
"@prisma/client": "^7.8.0",
|
||||||
"@types/leaflet": "^1.9.21",
|
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"leaflet": "^1.9.4",
|
|
||||||
"next": "16.2.6",
|
"next": "16.2.6",
|
||||||
"next-auth": "^5.0.0-beta.31",
|
"next-auth": "^5.0.0-beta.31",
|
||||||
"pg": "^8.21.0",
|
"pg": "^8.21.0",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "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"
|
"stripe": "^18.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
@ -38,13 +26,11 @@
|
||||||
"@types/node": "^20.19.41",
|
"@types/node": "^20.19.41",
|
||||||
"@types/react": "^19.2.15",
|
"@types/react": "^19.2.15",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitest/coverage-v8": "^3.2.4",
|
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"eslint": "^9.39.4",
|
"eslint": "^9.39.4",
|
||||||
"eslint-config-next": "^16.2.6",
|
"eslint-config-next": "^16.2.6",
|
||||||
"prisma": "^7.8.0",
|
"prisma": "^7.8.0",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3"
|
||||||
"vitest": "^3.2.4"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
CREATE TABLE "AuditLog" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"scope" TEXT NOT NULL,
|
|
||||||
"event" TEXT NOT NULL,
|
|
||||||
"target" TEXT,
|
|
||||||
"actorEmail" TEXT,
|
|
||||||
"details" JSONB NOT NULL DEFAULT '{}',
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
CREATE INDEX "AuditLog_scope_idx" ON "AuditLog"("scope");
|
|
||||||
CREATE INDEX "AuditLog_event_idx" ON "AuditLog"("event");
|
|
||||||
CREATE INDEX "AuditLog_actorEmail_idx" ON "AuditLog"("actorEmail");
|
|
||||||
CREATE INDEX "AuditLog_createdAt_idx" ON "AuditLog"("createdAt");
|
|
||||||
|
|
||||||
CREATE TABLE "Setting" (
|
|
||||||
"key" TEXT NOT NULL,
|
|
||||||
"value" JSONB NOT NULL DEFAULT '{}',
|
|
||||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
|
||||||
"updatedBy" TEXT,
|
|
||||||
CONSTRAINT "Setting_pkey" PRIMARY KEY ("key")
|
|
||||||
);
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
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");
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
CREATE TABLE "Translation" (
|
|
||||||
"key" TEXT NOT NULL,
|
|
||||||
"lang" TEXT NOT NULL,
|
|
||||||
"value" TEXT NOT NULL,
|
|
||||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
|
||||||
"updatedBy" TEXT,
|
|
||||||
CONSTRAINT "Translation_pkey" PRIMARY KEY ("key", "lang")
|
|
||||||
);
|
|
||||||
CREATE INDEX "Translation_lang_idx" ON "Translation"("lang");
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
ALTER TABLE "Carbet" ADD COLUMN "nightlyPrice" DECIMAL(10,2) NOT NULL DEFAULT 0;
|
|
||||||
UPDATE "Carbet" SET "nightlyPrice" = 80 WHERE "nightlyPrice" = 0;
|
|
||||||
|
|
@ -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';
|
|
||||||
|
|
@ -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");
|
|
||||||
|
|
@ -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");
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -13,7 +13,6 @@ enum UserRole {
|
||||||
CE_MEMBER
|
CE_MEMBER
|
||||||
TOURIST
|
TOURIST
|
||||||
ADMIN
|
ADMIN
|
||||||
RENTAL_PROVIDER
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum CarbetStatus {
|
enum CarbetStatus {
|
||||||
|
|
@ -72,59 +71,16 @@ enum TransportMode {
|
||||||
}
|
}
|
||||||
|
|
||||||
model Organization {
|
model Organization {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String
|
name String
|
||||||
slug String @unique
|
slug String @unique
|
||||||
description String?
|
description String?
|
||||||
contactEmail String?
|
createdAt DateTime @default(now())
|
||||||
approved Boolean @default(false)
|
updatedAt DateTime @updatedAt
|
||||||
approvedAt DateTime?
|
|
||||||
approvedBy String?
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
members User[]
|
members User[]
|
||||||
carbetMemberships OrganizationCarbetMembership[]
|
|
||||||
rentalProviders RentalProvider[]
|
|
||||||
invites OrgInviteToken[]
|
|
||||||
|
|
||||||
@@index([name])
|
@@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 {
|
model User {
|
||||||
|
|
@ -141,13 +97,11 @@ model User {
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
organization Organization? @relation(fields: [organizationId], references: [id], onDelete: SetNull)
|
organization Organization? @relation(fields: [organizationId], references: [id], onDelete: SetNull)
|
||||||
carbets Carbet[] @relation("CarbetOwner")
|
carbets Carbet[] @relation("CarbetOwner")
|
||||||
bookings Booking[] @relation("BookingTenant")
|
bookings Booking[] @relation("BookingTenant")
|
||||||
reviews Review[] @relation("ReviewAuthor")
|
reviews Review[] @relation("ReviewAuthor")
|
||||||
subscriptions Subscription[]
|
subscriptions Subscription[]
|
||||||
rentalProviders RentalProvider[]
|
|
||||||
rentalBookings RentalBooking[] @relation("RentalBookingTenant")
|
|
||||||
|
|
||||||
@@index([organizationId])
|
@@index([organizationId])
|
||||||
@@index([role])
|
@@index([role])
|
||||||
|
|
@ -170,13 +124,6 @@ model Carbet {
|
||||||
// Détails d'accès route pour ROAD_AND_RIVER (GPS, distance, type de piste).
|
// Détails d'accès route pour ROAD_AND_RIVER (GPS, distance, type de piste).
|
||||||
roadAccessNote String?
|
roadAccessNote String?
|
||||||
capacity Int
|
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.
|
// Contraintes séjour (plugin min-stay). null = pas de contrainte.
|
||||||
minStayNights Int?
|
minStayNights Int?
|
||||||
maxStayNights Int?
|
maxStayNights Int?
|
||||||
|
|
@ -200,7 +147,6 @@ model Carbet {
|
||||||
bookings Booking[]
|
bookings Booking[]
|
||||||
reviews Review[]
|
reviews Review[]
|
||||||
subscriptions Subscription[]
|
subscriptions Subscription[]
|
||||||
organizations OrganizationCarbetMembership[]
|
|
||||||
|
|
||||||
@@index([ownerId])
|
@@index([ownerId])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
|
|
@ -296,8 +242,7 @@ model Booking {
|
||||||
carbet Carbet @relation(fields: [carbetId], references: [id], onDelete: Restrict)
|
carbet Carbet @relation(fields: [carbetId], references: [id], onDelete: Restrict)
|
||||||
tenant User @relation("BookingTenant", fields: [tenantId], references: [id], onDelete: Restrict)
|
tenant User @relation("BookingTenant", fields: [tenantId], references: [id], onDelete: Restrict)
|
||||||
|
|
||||||
review Review?
|
review Review?
|
||||||
rentalBookings RentalBooking[]
|
|
||||||
|
|
||||||
@@index([carbetId])
|
@@index([carbetId])
|
||||||
@@index([tenantId])
|
@@index([tenantId])
|
||||||
|
|
@ -381,239 +326,3 @@ model ContentPage {
|
||||||
@@index([category])
|
@@index([category])
|
||||||
@@index([published])
|
@@index([published])
|
||||||
}
|
}
|
||||||
|
|
||||||
model AuditLog {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
scope String
|
|
||||||
event String
|
|
||||||
target String?
|
|
||||||
actorEmail String?
|
|
||||||
details Json @default("{}")
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
|
|
||||||
@@index([scope])
|
|
||||||
@@index([event])
|
|
||||||
@@index([actorEmail])
|
|
||||||
@@index([createdAt])
|
|
||||||
}
|
|
||||||
|
|
||||||
model Setting {
|
|
||||||
key String @id
|
|
||||||
value Json @default("{}")
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
updatedBy String?
|
|
||||||
}
|
|
||||||
|
|
||||||
model Translation {
|
|
||||||
key String
|
|
||||||
lang String
|
|
||||||
value String
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
updatedBy String?
|
|
||||||
|
|
||||||
@@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])
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 1 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 208 B |
Binary file not shown.
|
Before Width: | Height: | Size: 1 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.2 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 3 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 3.4 KiB |
|
|
@ -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" }]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -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)"
|
|
||||||
|
|
@ -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 (
|
|
||||||
<>
|
|
||||||
<IfPluginEnabled
|
|
||||||
plugin="landing-hero"
|
|
||||||
fallback={
|
|
||||||
<div className="flex flex-1 items-center justify-center bg-zinc-50 px-6 dark:bg-black">
|
|
||||||
<main className="flex w-full max-w-2xl flex-col items-center gap-6 text-center">
|
|
||||||
<h1 className="text-4xl font-semibold tracking-tight text-black sm:text-5xl dark:text-zinc-50">
|
|
||||||
Karbé — carbets fluviaux de Guyane
|
|
||||||
</h1>
|
|
||||||
<p className="max-w-xl text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
|
||||||
La marketplace pour louer des carbets le long des fleuves de Guyane.
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-wrap items-center justify-center gap-3">
|
|
||||||
<Link
|
|
||||||
href="/decouvrir"
|
|
||||||
className="rounded-md bg-emerald-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-emerald-700"
|
|
||||||
>
|
|
||||||
Au fil de l'eau
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/carbets"
|
|
||||||
className="rounded-md border border-zinc-300 px-5 py-2.5 text-sm font-medium text-zinc-700 hover:bg-zinc-100 dark:border-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-900"
|
|
||||||
>
|
|
||||||
Catalogue
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<HeroSection />
|
|
||||||
</IfPluginEnabled>
|
|
||||||
|
|
||||||
<IfPluginEnabled plugin="landing-sections">
|
|
||||||
<ExperiencesSection />
|
|
||||||
<HowItWorksSection />
|
|
||||||
<CESection />
|
|
||||||
<TestimonialsSection />
|
|
||||||
<LandingFooter />
|
|
||||||
</IfPluginEnabled>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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<string, string> = {
|
|
||||||
ADMIN: "Admin",
|
|
||||||
OWNER: "Hôte",
|
|
||||||
RENTAL_PROVIDER: "Loueur matériel",
|
|
||||||
CE_MANAGER: "CE Manager",
|
|
||||||
CE_MEMBER: "CE Membre",
|
|
||||||
TOURIST: "Voyageur",
|
|
||||||
};
|
|
||||||
|
|
||||||
function fmtEur(n: number): string {
|
|
||||||
return n.toLocaleString("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 });
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function AdminAnalyticsPage() {
|
|
||||||
const [kpis, series] = await Promise.all([
|
|
||||||
getAdminGlobalKpis(),
|
|
||||||
getMonthlyRevenueSeries({ monthsBack: 12 }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto max-w-6xl space-y-6">
|
|
||||||
<header className="mt-2">
|
|
||||||
<h1 className="text-2xl font-semibold text-zinc-900">Analytics globaux</h1>
|
|
||||||
<p className="mt-1 text-sm text-zinc-500">
|
|
||||||
Vue d'ensemble plateforme : utilisateurs, activité 30 derniers jours, top performers.
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
|
||||||
<KpiCard label="Utilisateurs" value={kpis.usersTotal} />
|
|
||||||
<KpiCard label="Carbets publiés" value={kpis.carbetsPublished} />
|
|
||||||
<KpiCard label="Bookings 30j" value={kpis.bookings30d} />
|
|
||||||
<KpiCard label="CA 30j" value={fmtEur(kpis.revenue30d)} />
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="grid gap-4 lg:grid-cols-2">
|
|
||||||
<div className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
|
||||||
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">
|
|
||||||
Utilisateurs par rôle
|
|
||||||
</h2>
|
|
||||||
{kpis.usersTotal === 0 ? (
|
|
||||||
<p className="text-sm text-zinc-500">Aucun utilisateur.</p>
|
|
||||||
) : (
|
|
||||||
<ul className="space-y-1.5 text-sm">
|
|
||||||
{Object.entries(kpis.usersByRole)
|
|
||||||
.sort((a, b) => b[1] - a[1])
|
|
||||||
.map(([role, count]) => {
|
|
||||||
const pct = Math.round((count / kpis.usersTotal) * 100);
|
|
||||||
return (
|
|
||||||
<li key={role}>
|
|
||||||
<div className="flex items-baseline justify-between">
|
|
||||||
<span className="text-zinc-700">{ROLE_LABEL[role] ?? role}</span>
|
|
||||||
<span className="font-mono text-xs text-zinc-700">
|
|
||||||
{count} ({pct}%)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-0.5 h-1.5 overflow-hidden rounded-full bg-zinc-100">
|
|
||||||
<div
|
|
||||||
className="h-full bg-emerald-500"
|
|
||||||
style={{ width: `${pct}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
|
||||||
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">
|
|
||||||
Activité 30 derniers jours
|
|
||||||
</h2>
|
|
||||||
<ul className="space-y-2 text-sm">
|
|
||||||
<li className="flex items-baseline justify-between">
|
|
||||||
<span className="text-zinc-700">Bookings carbet</span>
|
|
||||||
<span className="font-mono font-semibold text-zinc-900">{kpis.bookings30d}</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-baseline justify-between">
|
|
||||||
<span className="text-zinc-700">Locations matériel</span>
|
|
||||||
<span className="font-mono font-semibold text-zinc-900">{kpis.rentals30d}</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-baseline justify-between border-t border-zinc-100 pt-2">
|
|
||||||
<span className="font-semibold text-zinc-900">Total CA 30j</span>
|
|
||||||
<span className="font-mono font-semibold text-emerald-700">
|
|
||||||
{fmtEur(kpis.revenue30d)}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
|
||||||
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">
|
|
||||||
Chiffre d'affaires mensuel
|
|
||||||
</h2>
|
|
||||||
<MonthlyRevenueChart data={series} />
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="grid gap-4 lg:grid-cols-2">
|
|
||||||
<div className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
|
||||||
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">
|
|
||||||
Top carbets (30j)
|
|
||||||
</h2>
|
|
||||||
{kpis.topCarbets.length === 0 ? (
|
|
||||||
<p className="text-sm text-zinc-500">Aucune réservation sur les 30 derniers jours.</p>
|
|
||||||
) : (
|
|
||||||
<ul className="space-y-2 text-sm">
|
|
||||||
{kpis.topCarbets.map((c, i) => (
|
|
||||||
<li key={c.carbetId} className="flex items-baseline justify-between">
|
|
||||||
<span>
|
|
||||||
<span className="mr-2 text-xs text-zinc-500">#{i + 1}</span>
|
|
||||||
<Link href={`/admin/carbets/${c.carbetId}`} className="text-zinc-900 hover:underline">
|
|
||||||
{c.title}
|
|
||||||
</Link>
|
|
||||||
</span>
|
|
||||||
<span className="font-mono text-zinc-700">{fmtEur(c.revenue)}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
|
||||||
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">
|
|
||||||
Top prestataires rental (30j)
|
|
||||||
</h2>
|
|
||||||
{kpis.topProviders.length === 0 ? (
|
|
||||||
<p className="text-sm text-zinc-500">Aucune location sur les 30 derniers jours.</p>
|
|
||||||
) : (
|
|
||||||
<ul className="space-y-2 text-sm">
|
|
||||||
{kpis.topProviders.map((p, i) => (
|
|
||||||
<li key={p.providerId} className="flex items-baseline justify-between">
|
|
||||||
<span>
|
|
||||||
<span className="mr-2 text-xs text-zinc-500">#{i + 1}</span>
|
|
||||||
<Link
|
|
||||||
href={`/admin/rental-providers/${p.providerId}`}
|
|
||||||
className="text-zinc-900 hover:underline"
|
|
||||||
>
|
|
||||||
{p.name}
|
|
||||||
</Link>
|
|
||||||
</span>
|
|
||||||
<span className="font-mono text-zinc-700">{fmtEur(p.revenue)}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function KpiCard({ label, value }: { label: string; value: string | number }) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg border border-zinc-200 bg-white px-4 py-3 shadow-sm">
|
|
||||||
<div className="text-[10px] uppercase tracking-wider text-zinc-500">{label}</div>
|
|
||||||
<div className="mt-1 text-2xl font-semibold text-zinc-900 font-mono">{value}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,134 +0,0 @@
|
||||||
import Link from "next/link";
|
|
||||||
import { listAuditAdmin, listAuditScopes } from "@/lib/admin/audit";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
type PageProps = {
|
|
||||||
searchParams: Promise<{
|
|
||||||
q?: string;
|
|
||||||
scope?: string;
|
|
||||||
actor?: string;
|
|
||||||
from?: string;
|
|
||||||
to?: string;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
|
|
||||||
function parseDate(v?: string): Date | undefined {
|
|
||||||
if (!v) return undefined;
|
|
||||||
const d = new Date(v);
|
|
||||||
return isNaN(d.getTime()) ? undefined : d;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function AuditAdminPage({ searchParams }: PageProps) {
|
|
||||||
const sp = await searchParams;
|
|
||||||
const filters = {
|
|
||||||
q: sp.q?.trim() || undefined,
|
|
||||||
scope: sp.scope?.trim() || undefined,
|
|
||||||
actor: sp.actor?.trim() || undefined,
|
|
||||||
from: parseDate(sp.from),
|
|
||||||
to: parseDate(sp.to),
|
|
||||||
};
|
|
||||||
const [rows, scopes] = await Promise.all([listAuditAdmin(filters), listAuditScopes()]);
|
|
||||||
const dateTimeFmt = new Intl.DateTimeFormat("fr-FR", {
|
|
||||||
day: "2-digit", month: "short", year: "2-digit", hour: "2-digit", minute: "2-digit",
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto max-w-7xl">
|
|
||||||
<header className="mb-5 mt-2 flex items-end justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-semibold text-zinc-900">Audit log</h1>
|
|
||||||
<p className="mt-1 text-sm text-zinc-500">
|
|
||||||
{rows.length} entrée{rows.length > 1 ? "s" : ""}
|
|
||||||
{rows.length === 300 ? " (limite atteinte — affinez les filtres)" : ""}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<form className="mb-4 flex flex-wrap items-center gap-2 rounded-lg border border-zinc-200 bg-white p-3" method="get">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="q"
|
|
||||||
defaultValue={filters.q ?? ""}
|
|
||||||
placeholder="Recherche événement, cible, acteur…"
|
|
||||||
className="flex-1 min-w-[200px] rounded-md border border-zinc-300 px-3 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
|
|
||||||
/>
|
|
||||||
<select
|
|
||||||
name="scope"
|
|
||||||
defaultValue={filters.scope ?? ""}
|
|
||||||
className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
|
|
||||||
>
|
|
||||||
<option value="">Tous scopes</option>
|
|
||||||
{scopes.map((s) => (
|
|
||||||
<option key={s} value={s}>{s}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="actor"
|
|
||||||
defaultValue={filters.actor ?? ""}
|
|
||||||
placeholder="Acteur (email)"
|
|
||||||
className="rounded-md border border-zinc-300 px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
|
|
||||||
/>
|
|
||||||
<label className="flex items-center gap-1 text-xs text-zinc-500">
|
|
||||||
Du
|
|
||||||
<input type="date" name="from" defaultValue={sp.from ?? ""} className="rounded-md border border-zinc-300 px-2 py-1 text-sm" />
|
|
||||||
</label>
|
|
||||||
<label className="flex items-center gap-1 text-xs text-zinc-500">
|
|
||||||
au
|
|
||||||
<input type="date" name="to" defaultValue={sp.to ?? ""} className="rounded-md border border-zinc-300 px-2 py-1 text-sm" />
|
|
||||||
</label>
|
|
||||||
<button type="submit" className="rounded-md bg-zinc-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-zinc-800">
|
|
||||||
Filtrer
|
|
||||||
</button>
|
|
||||||
{(filters.q || filters.scope || filters.actor || filters.from || filters.to) ? (
|
|
||||||
<Link href="/admin/audit" className="text-sm text-zinc-500 hover:text-zinc-900">
|
|
||||||
Réinit.
|
|
||||||
</Link>
|
|
||||||
) : null}
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div className="overflow-hidden rounded-lg border border-zinc-200 bg-white">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead className="border-b border-zinc-200 bg-zinc-50 text-xs uppercase tracking-wider text-zinc-500">
|
|
||||||
<tr>
|
|
||||||
<th className="px-3 py-2 text-left font-semibold">Quand</th>
|
|
||||||
<th className="px-3 py-2 text-left font-semibold">Scope</th>
|
|
||||||
<th className="px-3 py-2 text-left font-semibold">Événement</th>
|
|
||||||
<th className="px-3 py-2 text-left font-semibold">Cible</th>
|
|
||||||
<th className="px-3 py-2 text-left font-semibold">Acteur</th>
|
|
||||||
<th className="px-3 py-2 text-left font-semibold">Détails</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-zinc-100">
|
|
||||||
{rows.length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={6} className="px-3 py-8 text-center text-sm text-zinc-500">
|
|
||||||
Aucune entrée d'audit ne correspond aux filtres.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : null}
|
|
||||||
{rows.map((r) => (
|
|
||||||
<tr key={r.id} className="hover:bg-zinc-50 align-top">
|
|
||||||
<td className="px-3 py-2 text-[11px] font-mono text-zinc-500 whitespace-nowrap">
|
|
||||||
{dateTimeFmt.format(r.createdAt)}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 text-xs text-zinc-700 whitespace-nowrap">{r.scope}</td>
|
|
||||||
<td className="px-3 py-2 font-mono text-xs text-zinc-900 whitespace-nowrap">{r.event}</td>
|
|
||||||
<td className="px-3 py-2 font-mono text-[11px] text-zinc-600">
|
|
||||||
{r.target ? r.target.slice(0, 24) + (r.target.length > 24 ? "…" : "") : "—"}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 text-xs text-zinc-700">{r.actorEmail ?? "—"}</td>
|
|
||||||
<td className="px-3 py-2 font-mono text-[11px] text-zinc-600">
|
|
||||||
{r.details && typeof r.details === "object" && Object.keys(r.details as object).length > 0
|
|
||||||
? JSON.stringify(r.details)
|
|
||||||
: "—"}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -5,11 +5,9 @@ import { auth } from "@/auth";
|
||||||
import { BookingStatus, PaymentStatus, UserRole } from "@/generated/prisma/enums";
|
import { BookingStatus, PaymentStatus, UserRole } from "@/generated/prisma/enums";
|
||||||
import { requireRole } from "@/lib/authorization";
|
import { requireRole } from "@/lib/authorization";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { recordAudit } from "@/lib/admin/audit";
|
|
||||||
import { sendBookingConfirmed, sendBookingRefunded } from "@/lib/email";
|
|
||||||
|
|
||||||
async function audit(event: string, target: string, actor: string | null, details: Record<string, unknown>) {
|
async function audit(event: string, target: string, actor: string | null, details: unknown) {
|
||||||
await recordAudit({ scope: "admin.bookings", event, target, actorEmail: actor, details });
|
console.log(JSON.stringify({ scope: "admin.bookings", event, target, actor, details, at: new Date().toISOString() }));
|
||||||
}
|
}
|
||||||
|
|
||||||
const ALLOWED_STATUS = new Set<string>([
|
const ALLOWED_STATUS = new Set<string>([
|
||||||
|
|
@ -32,32 +30,11 @@ export async function updateBookingStatusAction(id: string, status: string) {
|
||||||
return { ok: false as const, error: "Statut invalide" };
|
return { ok: false as const, error: "Statut invalide" };
|
||||||
}
|
}
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const before = await prisma.booking.findUnique({
|
await prisma.booking.update({
|
||||||
where: { id },
|
|
||||||
select: { status: true },
|
|
||||||
});
|
|
||||||
const updated = await prisma.booking.update({
|
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { status: status as BookingStatus },
|
data: { status: status as BookingStatus },
|
||||||
include: {
|
|
||||||
tenant: { select: { email: true, firstName: true } },
|
|
||||||
carbet: { select: { title: true } },
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
await audit("booking.status.update", id, session?.user?.email ?? null, { status });
|
await audit("booking.status.update", id, session?.user?.email ?? null, { status });
|
||||||
if (
|
|
||||||
before?.status !== BookingStatus.CONFIRMED &&
|
|
||||||
updated.status === BookingStatus.CONFIRMED
|
|
||||||
) {
|
|
||||||
sendBookingConfirmed(
|
|
||||||
updated.tenant.email,
|
|
||||||
updated.tenant.firstName,
|
|
||||||
updated.id,
|
|
||||||
updated.carbet.title,
|
|
||||||
updated.startDate,
|
|
||||||
updated.endDate,
|
|
||||||
).catch(() => {});
|
|
||||||
}
|
|
||||||
revalidatePath("/admin/bookings");
|
revalidatePath("/admin/bookings");
|
||||||
revalidatePath(`/admin/bookings/${id}`);
|
revalidatePath(`/admin/bookings/${id}`);
|
||||||
return { ok: true as const };
|
return { ok: true as const };
|
||||||
|
|
@ -82,26 +59,14 @@ export async function updateBookingPaymentAction(id: string, paymentStatus: stri
|
||||||
export async function refundBookingAction(id: string) {
|
export async function refundBookingAction(id: string) {
|
||||||
await requireRole([UserRole.ADMIN]);
|
await requireRole([UserRole.ADMIN]);
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const updated = await prisma.booking.update({
|
await prisma.booking.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
paymentStatus: PaymentStatus.REFUNDED,
|
paymentStatus: PaymentStatus.REFUNDED,
|
||||||
status: BookingStatus.CANCELLED,
|
status: BookingStatus.CANCELLED,
|
||||||
},
|
},
|
||||||
include: {
|
|
||||||
tenant: { select: { email: true, firstName: true } },
|
|
||||||
carbet: { select: { title: true } },
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
await audit("booking.refund", id, session?.user?.email ?? null, {});
|
await audit("booking.refund", id, session?.user?.email ?? null, {});
|
||||||
sendBookingRefunded(
|
|
||||||
updated.tenant.email,
|
|
||||||
updated.tenant.firstName,
|
|
||||||
updated.id,
|
|
||||||
updated.carbet.title,
|
|
||||||
updated.amount.toString(),
|
|
||||||
updated.currency,
|
|
||||||
).catch(() => {});
|
|
||||||
revalidatePath("/admin/bookings");
|
revalidatePath("/admin/bookings");
|
||||||
revalidatePath(`/admin/bookings/${id}`);
|
revalidatePath(`/admin/bookings/${id}`);
|
||||||
return { ok: true as const };
|
return { ok: true as const };
|
||||||
|
|
|
||||||
|
|
@ -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<string | null>(null);
|
|
||||||
|
|
||||||
// Filtre les orgs non encore liées
|
|
||||||
const linkedIds = new Set(linked.map((l) => l.id));
|
|
||||||
const options = available.filter((o) => !linkedIds.has(o.id));
|
|
||||||
|
|
||||||
function link() {
|
|
||||||
if (!selectedOrgId) return;
|
|
||||||
setError(null);
|
|
||||||
startTransition(async () => {
|
|
||||||
const res = await linkAction(carbetId, selectedOrgId);
|
|
||||||
if (!res.ok) setError(res.error || "Échec de la liaison");
|
|
||||||
else setSelectedOrgId("");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function unlink(orgId: string) {
|
|
||||||
setError(null);
|
|
||||||
startTransition(async () => {
|
|
||||||
const res = await unlinkAction(carbetId, orgId);
|
|
||||||
if (!res.ok) setError(res.error || "Échec");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{linked.length === 0 ? (
|
|
||||||
<p className="text-sm text-zinc-500">
|
|
||||||
Aucune organisation liée. Le carbet est géré uniquement par son propriétaire individuel.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<ul className="divide-y divide-zinc-100 rounded-md border border-zinc-200 bg-white">
|
|
||||||
{linked.map((o) => (
|
|
||||||
<li
|
|
||||||
key={o.id}
|
|
||||||
className="flex items-center justify-between gap-3 px-3 py-2 text-sm"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<span className="font-medium text-zinc-900">{o.name}</span>
|
|
||||||
<span className="ml-2 text-[11px] text-zinc-500">/{o.slug}</span>
|
|
||||||
{!o.approved ? (
|
|
||||||
<span className="ml-2 rounded-full bg-amber-100 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-amber-800 ring-1 ring-inset ring-amber-300">
|
|
||||||
Pending
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={pending}
|
|
||||||
onClick={() => unlink(o.id)}
|
|
||||||
className="rounded border border-rose-200 bg-white px-2 py-1 text-[11px] text-rose-700 hover:bg-rose-50 disabled:opacity-60"
|
|
||||||
>
|
|
||||||
Délier
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{options.length > 0 ? (
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<select
|
|
||||||
value={selectedOrgId}
|
|
||||||
onChange={(e) => setSelectedOrgId(e.target.value)}
|
|
||||||
className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
|
|
||||||
>
|
|
||||||
<option value="">— Choisir une organisation à lier —</option>
|
|
||||||
{options.map((o) => (
|
|
||||||
<option key={o.id} value={o.id}>
|
|
||||||
{o.name} {o.approved ? "" : "(pending)"}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={pending || !selectedOrgId}
|
|
||||||
onClick={link}
|
|
||||||
className="rounded-md bg-emerald-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{pending ? "…" : "Lier"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-[11px] text-zinc-500">
|
|
||||||
Toutes les organisations existantes sont déjà liées à ce carbet.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<div className="rounded border border-rose-200 bg-rose-50 px-3 py-2 text-xs text-rose-700">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<p className="text-[11px] text-zinc-500">
|
|
||||||
Une organisation liée signifie que ses CE_MANAGERs peuvent éditer ce carbet en plus du
|
|
||||||
propriétaire nominal.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useTransition } from "react";
|
import { useState, useTransition } from "react";
|
||||||
|
import Image from "next/image";
|
||||||
import { addMediaAction, removeMediaAction, reorderMediaAction } from "../../actions";
|
import { addMediaAction, removeMediaAction, reorderMediaAction } from "../../actions";
|
||||||
import { FormField, inputCls, selectCls } from "@/components/admin/FormField";
|
import { FormField, inputCls, selectCls } from "@/components/admin/FormField";
|
||||||
|
|
||||||
|
|
@ -124,7 +125,7 @@ export function MediaManager({ carbetId, media: initial }: { carbetId: string; m
|
||||||
</select>
|
</select>
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
{/* Le serveur calcule un s3Key déterministe à partir de l'URL si vide. */}
|
<input type="hidden" name="s3Key" value={`external/${Date.now()}`} />
|
||||||
{error ? <div className="rounded border border-rose-200 bg-rose-50 px-2 py-1 text-xs text-rose-700">{error}</div> : null}
|
{error ? <div className="rounded border border-rose-200 bg-rose-50 px-2 py-1 text-xs text-rose-700">{error}</div> : null}
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,15 @@
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
import { MediaUploader } from "@/components/MediaUploader";
|
|
||||||
import { StatusBadge } from "@/components/admin/StatusBadge";
|
|
||||||
import {
|
import {
|
||||||
getCarbetForEdit,
|
getCarbetForEdit,
|
||||||
listOrganizationsForLink,
|
|
||||||
listOwners,
|
listOwners,
|
||||||
listPirogueProviders,
|
listPirogueProviders,
|
||||||
} from "@/lib/admin/carbets";
|
} from "@/lib/admin/carbets";
|
||||||
|
|
||||||
import { CarbetForm } from "../_components/CarbetForm";
|
import { CarbetForm } from "../_components/CarbetForm";
|
||||||
import {
|
import { StatusBadge } from "@/components/admin/StatusBadge";
|
||||||
linkCarbetToOrganizationAction,
|
import { MediaManager } from "./_components/MediaManager";
|
||||||
unlinkCarbetFromOrganizationAction,
|
|
||||||
updateCarbetAction,
|
|
||||||
} from "../actions";
|
|
||||||
import { CarbetMemberships } from "./_components/CarbetMemberships";
|
|
||||||
import { StatusActions } from "./_components/StatusActions";
|
import { StatusActions } from "./_components/StatusActions";
|
||||||
|
import { updateCarbetAction } from "../actions";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
|
@ -25,11 +17,10 @@ type PageProps = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
export default async function EditCarbetPage({ params }: PageProps) {
|
export default async function EditCarbetPage({ params }: PageProps) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const [carbet, owners, providers, organizations] = await Promise.all([
|
const [carbet, owners, providers] = await Promise.all([
|
||||||
getCarbetForEdit(id),
|
getCarbetForEdit(id),
|
||||||
listOwners(),
|
listOwners(),
|
||||||
listPirogueProviders(),
|
listPirogueProviders(),
|
||||||
listOrganizationsForLink(),
|
|
||||||
]);
|
]);
|
||||||
if (!carbet) notFound();
|
if (!carbet) notFound();
|
||||||
|
|
||||||
|
|
@ -37,14 +28,6 @@ export default async function EditCarbetPage({ params }: PageProps) {
|
||||||
"use server";
|
"use server";
|
||||||
return await updateCarbetAction(id, fd);
|
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 (
|
return (
|
||||||
<div className="mx-auto max-w-5xl space-y-6">
|
<div className="mx-auto max-w-5xl space-y-6">
|
||||||
|
|
@ -78,40 +61,16 @@ export default async function EditCarbetPage({ params }: PageProps) {
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section className="mb-6 rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
<MediaManager
|
||||||
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">
|
carbetId={carbet.id}
|
||||||
Organisations co-gestionnaires (CE)
|
media={carbet.media.map((m) => ({
|
||||||
</h2>
|
id: m.id,
|
||||||
<CarbetMemberships
|
type: m.type,
|
||||||
carbetId={carbet.id}
|
s3Key: m.s3Key,
|
||||||
linked={carbet.organizations.map((m) => ({
|
s3Url: m.s3Url,
|
||||||
id: m.organization.id,
|
sortOrder: m.sortOrder,
|
||||||
name: m.organization.name,
|
}))}
|
||||||
slug: m.organization.slug,
|
/>
|
||||||
approved: m.organization.approved,
|
|
||||||
addedAt: m.addedAt,
|
|
||||||
}))}
|
|
||||||
available={organizations}
|
|
||||||
linkAction={linkThis}
|
|
||||||
unlinkAction={unlinkThis}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="mb-6 rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
|
||||||
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">
|
|
||||||
Médias
|
|
||||||
</h2>
|
|
||||||
<MediaUploader
|
|
||||||
carbetId={carbet.id}
|
|
||||||
initialMedia={carbet.media.map((m) => ({
|
|
||||||
id: m.id,
|
|
||||||
type: m.type,
|
|
||||||
s3Key: m.s3Key,
|
|
||||||
s3Url: m.s3Url,
|
|
||||||
sortOrder: m.sortOrder,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<CarbetForm
|
<CarbetForm
|
||||||
owners={owners}
|
owners={owners}
|
||||||
|
|
@ -128,12 +87,7 @@ export default async function EditCarbetPage({ params }: PageProps) {
|
||||||
latitude: carbet.latitude.toString(),
|
latitude: carbet.latitude.toString(),
|
||||||
longitude: carbet.longitude.toString(),
|
longitude: carbet.longitude.toString(),
|
||||||
capacity: carbet.capacity,
|
capacity: carbet.capacity,
|
||||||
nightlyPrice: carbet.nightlyPrice.toString(),
|
|
||||||
accessType: carbet.accessType,
|
accessType: carbet.accessType,
|
||||||
roadAccess: carbet.roadAccess,
|
|
||||||
electricity: carbet.electricity,
|
|
||||||
gsmAtCarbet: carbet.gsmAtCarbet,
|
|
||||||
gsmExitDistanceKm: carbet.gsmExitDistanceKm !== null ? carbet.gsmExitDistanceKm.toString() : null,
|
|
||||||
roadAccessNote: carbet.roadAccessNote,
|
roadAccessNote: carbet.roadAccessNote,
|
||||||
pirogueDurationMin: carbet.pirogueDurationMin,
|
pirogueDurationMin: carbet.pirogueDurationMin,
|
||||||
minStayNights: carbet.minStayNights,
|
minStayNights: carbet.minStayNights,
|
||||||
|
|
|
||||||
|
|
@ -18,12 +18,7 @@ export type CarbetFormInitial = {
|
||||||
latitude?: number | string;
|
latitude?: number | string;
|
||||||
longitude?: number | string;
|
longitude?: number | string;
|
||||||
capacity?: number;
|
capacity?: number;
|
||||||
nightlyPrice?: number | string;
|
|
||||||
accessType?: string;
|
accessType?: string;
|
||||||
roadAccess?: string | null;
|
|
||||||
electricity?: string | null;
|
|
||||||
gsmAtCarbet?: boolean;
|
|
||||||
gsmExitDistanceKm?: number | string | null;
|
|
||||||
roadAccessNote?: string | null;
|
roadAccessNote?: string | null;
|
||||||
pirogueDurationMin?: number | null;
|
pirogueDurationMin?: number | null;
|
||||||
minStayNights?: number | null;
|
minStayNights?: number | null;
|
||||||
|
|
@ -193,66 +188,9 @@ export function CarbetForm({ initial = {}, owners, providers, action, submitLabe
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Critères opérationnels */}
|
{/* Séjour */}
|
||||||
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
||||||
<h2 className="mb-1 text-sm font-semibold uppercase tracking-wider text-zinc-500">
|
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-zinc-500">Séjour</h2>
|
||||||
Critères opérationnels
|
|
||||||
</h2>
|
|
||||||
<p className="mb-4 text-xs text-zinc-500">
|
|
||||||
Les 4 dealbreakers d'un séjour en carbet guyanais. Indispensable pour les filtres recherche.
|
|
||||||
</p>
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
||||||
<FormField label="🛣️ Accès route" hint="Praticabilité de l'accès depuis la route">
|
|
||||||
<select name="roadAccess" defaultValue={initial.roadAccess ?? ""} className={selectCls}>
|
|
||||||
<option value="">— non précisé —</option>
|
|
||||||
<option value="ALL_YEAR">🛣️ Toute saison</option>
|
|
||||||
<option value="DRY_SEASON_ONLY">🟠 Saison sèche uniquement</option>
|
|
||||||
<option value="NONE">🛶 Pirogue uniquement</option>
|
|
||||||
</select>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<FormField label="⚡ Électricité" hint="Comment est alimenté le carbet ?">
|
|
||||||
<select name="electricity" defaultValue={initial.electricity ?? ""} className={selectCls}>
|
|
||||||
<option value="">— non précisé —</option>
|
|
||||||
<option value="EDF">⚡ EDF / raccordé réseau</option>
|
|
||||||
<option value="GENERATOR_READY">🔌 Préinstallation groupe électrogène</option>
|
|
||||||
<option value="SOLAR">☀️ Solaire</option>
|
|
||||||
<option value="NONE">🕯️ Aucune électricité</option>
|
|
||||||
</select>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<FormField label="📶 Réseau GSM au carbet" hint="Téléphone capte directement sur place ?">
|
|
||||||
<select
|
|
||||||
name="gsmAtCarbet"
|
|
||||||
defaultValue={initial.gsmAtCarbet ? "yes" : "no"}
|
|
||||||
className={selectCls}
|
|
||||||
>
|
|
||||||
<option value="yes">✅ Oui, signal au carbet</option>
|
|
||||||
<option value="no">❌ Non, zone sans réseau</option>
|
|
||||||
</select>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
label="📵 Distance pour atteindre le réseau (km)"
|
|
||||||
hint="Si pas de réseau au carbet — sinon laisser vide"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
name="gsmExitDistanceKm"
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
max={50}
|
|
||||||
step="0.1"
|
|
||||||
defaultValue={initial.gsmExitDistanceKm?.toString() ?? ""}
|
|
||||||
placeholder="ex. 1.5"
|
|
||||||
className={inputCls}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Séjour & tarif */}
|
|
||||||
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
|
||||||
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-zinc-500">Séjour & tarif</h2>
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
|
||||||
<FormField label="Capacité" required hint="Voyageurs max">
|
<FormField label="Capacité" required hint="Voyageurs max">
|
||||||
<input
|
<input
|
||||||
|
|
@ -265,17 +203,6 @@ export function CarbetForm({ initial = {}, owners, providers, action, submitLabe
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Prix / nuit (€)" required hint="Pour le carbet entier.">
|
|
||||||
<input
|
|
||||||
name="nightlyPrice"
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
step="0.01"
|
|
||||||
defaultValue={initial.nightlyPrice?.toString() ?? ""}
|
|
||||||
className={inputCls}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
<FormField label="Capacité min recommandée" hint="Facultatif">
|
<FormField label="Capacité min recommandée" hint="Facultatif">
|
||||||
<input
|
<input
|
||||||
name="minCapacity"
|
name="minCapacity"
|
||||||
|
|
|
||||||
|
|
@ -5,14 +5,11 @@ import { redirect } from "next/navigation";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { requireRole } from "@/lib/authorization";
|
import { requireRole } from "@/lib/authorization";
|
||||||
import { recordAudit } from "@/lib/admin/audit";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import {
|
import {
|
||||||
AccessType,
|
AccessType,
|
||||||
CarbetStatus,
|
CarbetStatus,
|
||||||
Electricity,
|
|
||||||
MediaType,
|
MediaType,
|
||||||
RoadAccess,
|
|
||||||
TransportMode,
|
TransportMode,
|
||||||
UserRole,
|
UserRole,
|
||||||
} from "@/generated/prisma/enums";
|
} from "@/generated/prisma/enums";
|
||||||
|
|
@ -29,18 +26,7 @@ const baseCarbetSchema = z.object({
|
||||||
latitude: z.coerce.number().min(-90).max(90),
|
latitude: z.coerce.number().min(-90).max(90),
|
||||||
longitude: z.coerce.number().min(-180).max(180),
|
longitude: z.coerce.number().min(-180).max(180),
|
||||||
capacity: z.coerce.number().int().min(1).max(100),
|
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]),
|
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(),
|
roadAccessNote: z.string().trim().max(1000).optional().nullable(),
|
||||||
pirogueDurationMin: z.coerce.number().int().min(0).max(1440).optional().nullable(),
|
pirogueDurationMin: z.coerce.number().int().min(0).max(1440).optional().nullable(),
|
||||||
minStayNights: z.coerce.number().int().min(1).max(365).optional().nullable(),
|
minStayNights: z.coerce.number().int().min(1).max(365).optional().nullable(),
|
||||||
|
|
@ -65,11 +51,9 @@ function parseFromFormData(fd: FormData) {
|
||||||
if (typeof v === "string") obj[k] = v;
|
if (typeof v === "string") obj[k] = v;
|
||||||
}
|
}
|
||||||
// Normalise les champs optionnels nullables
|
// 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)),
|
(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;
|
return obj;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -213,53 +197,23 @@ export async function reorderMediaAction(carbetId: string, mediaId: string, dire
|
||||||
return { ok: true as const };
|
return { ok: true as const };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function linkCarbetToOrganizationAction(carbetId: string, organizationId: string) {
|
/**
|
||||||
await requireRole([UserRole.ADMIN]);
|
* Audit léger : log dans la console (Sprint 5 ajoutera une table AuditLog).
|
||||||
const session = await auth();
|
* Pour l'instant on a au moins une trace dans les logs du container.
|
||||||
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(
|
async function audit(
|
||||||
event: string,
|
action: string,
|
||||||
entityId: string,
|
entityId: string,
|
||||||
actor: string | null,
|
actor: string | null,
|
||||||
payload: Record<string, unknown>,
|
payload: Record<string, unknown>,
|
||||||
) {
|
) {
|
||||||
await recordAudit({
|
console.log(
|
||||||
scope: "admin.carbets",
|
JSON.stringify({
|
||||||
event,
|
audit: action,
|
||||||
target: entityId,
|
actor,
|
||||||
actorEmail: actor,
|
entityId,
|
||||||
details: payload,
|
payload,
|
||||||
});
|
at: new Date().toISOString(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -99,7 +99,6 @@ export default async function CarbetsAdminPage({ searchParams }: PageProps) {
|
||||||
<th className="px-4 py-2 text-left font-semibold">Fleuve</th>
|
<th className="px-4 py-2 text-left font-semibold">Fleuve</th>
|
||||||
<th className="px-4 py-2 text-left font-semibold">Accès</th>
|
<th className="px-4 py-2 text-left font-semibold">Accès</th>
|
||||||
<th className="px-4 py-2 text-right font-semibold">Cap.</th>
|
<th className="px-4 py-2 text-right font-semibold">Cap.</th>
|
||||||
<th className="px-4 py-2 text-right font-semibold">€/nuit</th>
|
|
||||||
<th className="px-4 py-2 text-right font-semibold">Médias</th>
|
<th className="px-4 py-2 text-right font-semibold">Médias</th>
|
||||||
<th className="px-4 py-2 text-right font-semibold">Résas</th>
|
<th className="px-4 py-2 text-right font-semibold">Résas</th>
|
||||||
<th className="px-4 py-2 text-left font-semibold">Propriétaire</th>
|
<th className="px-4 py-2 text-left font-semibold">Propriétaire</th>
|
||||||
|
|
@ -110,7 +109,7 @@ export default async function CarbetsAdminPage({ searchParams }: PageProps) {
|
||||||
<tbody className="divide-y divide-zinc-100">
|
<tbody className="divide-y divide-zinc-100">
|
||||||
{carbets.length === 0 ? (
|
{carbets.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={10} className="px-4 py-8 text-center text-sm text-zinc-500">
|
<td colSpan={9} className="px-4 py-8 text-center text-sm text-zinc-500">
|
||||||
Aucun carbet ne correspond aux filtres.
|
Aucun carbet ne correspond aux filtres.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -130,7 +129,6 @@ export default async function CarbetsAdminPage({ searchParams }: PageProps) {
|
||||||
{c.accessType === AccessType.RIVER_ONLY ? "🛶 Fleuve" : "🛣️ Route+fleuve"}
|
{c.accessType === AccessType.RIVER_ONLY ? "🛶 Fleuve" : "🛣️ Route+fleuve"}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2 text-right font-mono text-zinc-700">{c.capacity}</td>
|
<td className="px-4 py-2 text-right font-mono text-zinc-700">{c.capacity}</td>
|
||||||
<td className="px-4 py-2 text-right font-mono text-zinc-700">{Number(c.nightlyPrice).toFixed(0)}</td>
|
|
||||||
<td className="px-4 py-2 text-right font-mono text-zinc-700">{c.mediaCount}</td>
|
<td className="px-4 py-2 text-right font-mono text-zinc-700">{c.mediaCount}</td>
|
||||||
<td className="px-4 py-2 text-right font-mono text-zinc-700">{c.bookingsCount}</td>
|
<td className="px-4 py-2 text-right font-mono text-zinc-700">{c.bookingsCount}</td>
|
||||||
<td className="px-4 py-2 text-zinc-700">{c.ownerName}</td>
|
<td className="px-4 py-2 text-zinc-700">{c.ownerName}</td>
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
type Page = {
|
type Page = {
|
||||||
slug: string;
|
slug: string;
|
||||||
lang: string;
|
|
||||||
title: string;
|
title: string;
|
||||||
body: string;
|
body: string;
|
||||||
category: string;
|
category: string;
|
||||||
|
|
@ -26,14 +25,11 @@ export default function EditorForm({ page }: { page: Page }) {
|
||||||
setMsg(null);
|
setMsg(null);
|
||||||
setErr(null);
|
setErr(null);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
const res = await fetch(`/api/admin/content-pages/${encodeURIComponent(page.slug)}`, {
|
||||||
`/api/admin/content-pages/${encodeURIComponent(page.slug)}?lang=${encodeURIComponent(page.lang)}`,
|
method: "PATCH",
|
||||||
{
|
headers: { "Content-Type": "application/json" },
|
||||||
method: "PATCH",
|
body: JSON.stringify({ title, body, published }),
|
||||||
headers: { "Content-Type": "application/json" },
|
});
|
||||||
body: JSON.stringify({ title, body, published }),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const j = await res.json().catch(() => ({}));
|
const j = await res.json().catch(() => ({}));
|
||||||
throw new Error(j?.error || `HTTP ${res.status}`);
|
throw new Error(j?.error || `HTTP ${res.status}`);
|
||||||
|
|
|
||||||
|
|
@ -2,90 +2,46 @@ import { notFound } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { requireRole } from "@/lib/authorization";
|
import { requireRole } from "@/lib/authorization";
|
||||||
import { UserRole } from "@/generated/prisma/enums";
|
import { UserRole } from "@/generated/prisma/enums";
|
||||||
|
import { getContentPage } from "@/lib/content-pages";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import EditorForm from "./_components/EditorForm";
|
import EditorForm from "./_components/EditorForm";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
type PageProps = {
|
type PageProps = { params: Promise<{ slug: string }> };
|
||||||
params: Promise<{ slug: string }>;
|
|
||||||
searchParams: Promise<{ lang?: string }>;
|
|
||||||
};
|
|
||||||
|
|
||||||
function normalizeLang(v: string | undefined): string {
|
export default async function EditContentPage({ params }: PageProps) {
|
||||||
if (!v) return "fr";
|
|
||||||
const l = v.toLowerCase().trim();
|
|
||||||
return /^[a-z]{2}$/.test(l) ? l : "fr";
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function EditContentPage({ params, searchParams }: PageProps) {
|
|
||||||
await requireRole([UserRole.ADMIN]);
|
await requireRole([UserRole.ADMIN]);
|
||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
const sp = await searchParams;
|
// Pas getContentPage : il filtre published=true. Ici on veut tout voir.
|
||||||
const lang = normalizeLang(sp.lang);
|
// Admin édite la version FR par défaut. (Édition EN = future feature.)
|
||||||
|
const row = await prisma.contentPage.findUnique({
|
||||||
const [row, siblings] = await Promise.all([
|
where: { slug_lang: { slug, lang: "fr" } },
|
||||||
prisma.contentPage.findUnique({ where: { slug_lang: { slug, lang } } }),
|
});
|
||||||
prisma.contentPage.findMany({
|
|
||||||
where: { slug },
|
|
||||||
select: { lang: true, title: true, published: true, updatedAt: true },
|
|
||||||
orderBy: { lang: "asc" },
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
if (!row) notFound();
|
if (!row) notFound();
|
||||||
|
// Re-construction du type minimal attendu par le formulaire.
|
||||||
const page = {
|
const page = {
|
||||||
slug: row.slug,
|
slug: row.slug,
|
||||||
lang: row.lang,
|
|
||||||
title: row.title,
|
title: row.title,
|
||||||
body: row.body,
|
body: row.body,
|
||||||
category: row.category,
|
category: row.category,
|
||||||
published: row.published,
|
published: row.published,
|
||||||
|
updatedAt: row.updatedAt,
|
||||||
};
|
};
|
||||||
|
// Mute eslint sur le _ = getContentPage (gardé importé pour la cohérence future).
|
||||||
|
void getContentPage;
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-4xl">
|
<div className="mx-auto max-w-4xl px-4 py-8 sm:px-6 lg:px-8">
|
||||||
<header className="mt-2">
|
<Link
|
||||||
<Link href="/admin/content-pages" className="text-xs text-zinc-500 hover:text-zinc-900">
|
href="/admin/content-pages"
|
||||||
← Toutes les pages
|
className="text-sm text-gray-600 hover:text-gray-900"
|
||||||
</Link>
|
>
|
||||||
<h1 className="mt-1 flex flex-wrap items-center gap-3 text-2xl font-semibold text-zinc-900">
|
← Toutes les pages
|
||||||
{page.title}
|
</Link>
|
||||||
<span className="rounded-full bg-zinc-900 px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wider text-white">
|
<h1 className="mt-3 text-2xl font-semibold">Éditer · {page.title}</h1>
|
||||||
{page.lang}
|
<p className="mt-1 text-sm text-gray-600">
|
||||||
</span>
|
URL publique : <code>/{page.slug}</code>
|
||||||
</h1>
|
</p>
|
||||||
<p className="mt-1 text-sm text-zinc-500">
|
|
||||||
URL publique : <code>/{page.slug}</code>
|
|
||||||
{page.lang !== "fr" ? ` · variante ${page.lang}` : ""}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{siblings.length > 1 ? (
|
|
||||||
<nav className="mt-3 flex flex-wrap items-center gap-1.5 text-xs">
|
|
||||||
<span className="text-zinc-500">Versions :</span>
|
|
||||||
{siblings.map((s) => {
|
|
||||||
const active = s.lang === page.lang;
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={s.lang}
|
|
||||||
href={`/admin/content-pages/${encodeURIComponent(slug)}?lang=${s.lang}`}
|
|
||||||
className={
|
|
||||||
"rounded-md px-2.5 py-1 font-semibold uppercase tracking-wider transition " +
|
|
||||||
(active
|
|
||||||
? "bg-zinc-900 text-white"
|
|
||||||
: "border border-zinc-300 bg-white text-zinc-700 hover:bg-zinc-50")
|
|
||||||
}
|
|
||||||
title={s.title + (s.published ? "" : " (dépublié)")}
|
|
||||||
>
|
|
||||||
{s.lang}
|
|
||||||
{!s.published ? " ·" : ""}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
) : null}
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<EditorForm page={page} />
|
<EditorForm page={page} />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -10,146 +10,50 @@ const CATEGORY_LABEL: Record<string, string> = {
|
||||||
legal: "Légales",
|
legal: "Légales",
|
||||||
};
|
};
|
||||||
|
|
||||||
type Translation = {
|
|
||||||
lang: string;
|
|
||||||
title: string;
|
|
||||||
published: boolean;
|
|
||||||
updatedAt: Date;
|
|
||||||
};
|
|
||||||
|
|
||||||
type GroupedPage = {
|
|
||||||
slug: string;
|
|
||||||
category: string;
|
|
||||||
translations: Translation[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async function ContentPagesAdminPage() {
|
export default async function ContentPagesAdminPage() {
|
||||||
await requireRole([UserRole.ADMIN]);
|
await requireRole([UserRole.ADMIN]);
|
||||||
const rows = await listContentPages();
|
const pages = await listContentPages();
|
||||||
|
|
||||||
// Regrouper par slug — chaque slug peut avoir plusieurs traductions.
|
const byCategory = pages.reduce<Record<string, typeof pages>>((acc, p) => {
|
||||||
const bySlug = new Map<string, GroupedPage>();
|
|
||||||
for (const r of rows) {
|
|
||||||
const existing = bySlug.get(r.slug);
|
|
||||||
const t: Translation = {
|
|
||||||
lang: r.lang,
|
|
||||||
title: r.title,
|
|
||||||
published: r.published,
|
|
||||||
updatedAt: r.updatedAt,
|
|
||||||
};
|
|
||||||
if (existing) {
|
|
||||||
existing.translations.push(t);
|
|
||||||
} else {
|
|
||||||
bySlug.set(r.slug, { slug: r.slug, category: r.category, translations: [t] });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const pages = Array.from(bySlug.values()).sort((a, b) => a.slug.localeCompare(b.slug));
|
|
||||||
|
|
||||||
const byCategory = pages.reduce<Record<string, GroupedPage[]>>((acc, p) => {
|
|
||||||
(acc[p.category] ??= []).push(p);
|
(acc[p.category] ??= []).push(p);
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
|
|
||||||
const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short", year: "2-digit" });
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-5xl">
|
<div className="mx-auto max-w-5xl px-4 py-8 sm:px-6 lg:px-8">
|
||||||
<header className="mb-5 mt-2">
|
<h1 className="text-2xl font-semibold">Pages éditoriales</h1>
|
||||||
<h1 className="text-2xl font-semibold text-zinc-900">Pages éditoriales</h1>
|
<p className="mt-2 text-sm text-gray-600">
|
||||||
<p className="mt-2 text-sm text-zinc-600">
|
Pages markdown affichées dans le site public. La catégorie « Général »
|
||||||
Pages markdown servies par le site public. Chaque page existe en une ou
|
est gérée par le plugin <code>content-pages</code>, la catégorie « Légales »
|
||||||
plusieurs langues — utilisez le bouton de la langue voulue pour éditer
|
par <code>legal-pages</code>. Désactiver le plugin dépublie ses pages
|
||||||
la bonne version.
|
sans les supprimer.
|
||||||
</p>
|
</p>
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="space-y-8">
|
<div className="mt-6 space-y-8">
|
||||||
{Object.entries(byCategory).map(([cat, list]) => (
|
{Object.entries(byCategory).map(([cat, list]) => (
|
||||||
<section key={cat}>
|
<section key={cat}>
|
||||||
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wider text-zinc-500">
|
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-gray-500">
|
||||||
{CATEGORY_LABEL[cat] ?? cat}
|
{CATEGORY_LABEL[cat] ?? cat}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="overflow-hidden rounded-lg border border-zinc-200 bg-white">
|
<ul className="divide-y divide-gray-200 rounded-lg border border-gray-200 bg-white">
|
||||||
<table className="w-full text-sm">
|
{list.map((p) => (
|
||||||
<thead className="border-b border-zinc-200 bg-zinc-50 text-xs uppercase tracking-wider text-zinc-500">
|
<li key={p.slug} className="flex items-center justify-between gap-4 px-4 py-3">
|
||||||
<tr>
|
<div>
|
||||||
<th className="px-4 py-2 text-left font-semibold">Slug</th>
|
<div className="font-medium">{p.title}</div>
|
||||||
<th className="px-4 py-2 text-left font-semibold">Titre (FR)</th>
|
<div className="text-xs text-gray-500">
|
||||||
<th className="px-4 py-2 text-left font-semibold">Traductions</th>
|
<code>/{p.slug}</code> · {p.published ? "publié" : "dépublié"} ·
|
||||||
<th className="px-4 py-2 text-right font-semibold">MAJ</th>
|
mis à jour le {new Date(p.updatedAt).toLocaleDateString("fr-FR")}
|
||||||
<th className="px-4 py-2 text-right font-semibold">Éditer</th>
|
</div>
|
||||||
</tr>
|
</div>
|
||||||
</thead>
|
<Link
|
||||||
<tbody className="divide-y divide-zinc-100">
|
href={`/admin/content-pages/${encodeURIComponent(p.slug)}`}
|
||||||
{list.map((p) => {
|
className="rounded-full bg-gray-900 px-3 py-1 text-xs font-semibold text-white hover:bg-gray-800"
|
||||||
const fr = p.translations.find((t) => t.lang === "fr");
|
>
|
||||||
const others = p.translations.filter((t) => t.lang !== "fr").sort((a, b) => a.lang.localeCompare(b.lang));
|
Éditer
|
||||||
const lastUpdated = p.translations
|
</Link>
|
||||||
.map((t) => t.updatedAt.getTime())
|
</li>
|
||||||
.reduce((a, b) => Math.max(a, b), 0);
|
))}
|
||||||
return (
|
</ul>
|
||||||
<tr key={p.slug} className="hover:bg-zinc-50">
|
|
||||||
<td className="px-4 py-2 font-mono text-[11px] text-zinc-700">/{p.slug}</td>
|
|
||||||
<td className="px-4 py-2">
|
|
||||||
{fr ? (
|
|
||||||
<>
|
|
||||||
<span className="font-medium text-zinc-900">{fr.title}</span>
|
|
||||||
{!fr.published ? (
|
|
||||||
<span className="ml-2 rounded-full bg-amber-100 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-amber-800 ring-1 ring-inset ring-amber-300">
|
|
||||||
dépublié
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<span className="text-zinc-400">— (pas de version FR)</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2 text-xs text-zinc-700">
|
|
||||||
{others.length === 0 ? (
|
|
||||||
<span className="text-zinc-400">—</span>
|
|
||||||
) : (
|
|
||||||
<span className="flex flex-wrap gap-1">
|
|
||||||
{others.map((t) => (
|
|
||||||
<span
|
|
||||||
key={t.lang}
|
|
||||||
className={
|
|
||||||
"inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider ring-1 ring-inset " +
|
|
||||||
(t.published
|
|
||||||
? "bg-emerald-100 text-emerald-800 ring-emerald-300"
|
|
||||||
: "bg-zinc-100 text-zinc-500 ring-zinc-300")
|
|
||||||
}
|
|
||||||
title={t.title}
|
|
||||||
>
|
|
||||||
{t.lang}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2 text-right text-[11px] text-zinc-500">
|
|
||||||
{lastUpdated ? dateFmt.format(new Date(lastUpdated)) : "—"}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2 text-right">
|
|
||||||
<span className="inline-flex flex-wrap justify-end gap-1">
|
|
||||||
{p.translations
|
|
||||||
.sort((a, b) => (a.lang === "fr" ? -1 : b.lang === "fr" ? 1 : a.lang.localeCompare(b.lang)))
|
|
||||||
.map((t) => (
|
|
||||||
<Link
|
|
||||||
key={t.lang}
|
|
||||||
href={`/admin/content-pages/${encodeURIComponent(p.slug)}?lang=${t.lang}`}
|
|
||||||
className="rounded-md bg-zinc-900 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-wider text-white hover:bg-zinc-800"
|
|
||||||
>
|
|
||||||
{t.lang}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,169 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useMemo, useState, useTransition } from "react";
|
|
||||||
import { saveHomeTranslationsAction } from "../actions";
|
|
||||||
|
|
||||||
type Row = {
|
|
||||||
key: string;
|
|
||||||
baseFr: string;
|
|
||||||
baseEn: string;
|
|
||||||
overrideFr: string | null;
|
|
||||||
overrideEn: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Section = {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
description: string;
|
|
||||||
rows: Row[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
sections: Section[];
|
|
||||||
};
|
|
||||||
|
|
||||||
function autoRows(text: string): number {
|
|
||||||
const lines = text.split("\n").length;
|
|
||||||
return Math.min(8, Math.max(1, lines));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function HomeTranslationsForm({ sections }: Props) {
|
|
||||||
const [pending, startTransition] = useTransition();
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [success, setSuccess] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// État local : on garde uniquement la valeur courante (initialisée avec override ?? base).
|
|
||||||
// Le baseValue est posé en input caché et sert au backend pour décider override vs reset.
|
|
||||||
const initial = useMemo(() => {
|
|
||||||
const m = new Map<string, { fr: string; en: string }>();
|
|
||||||
for (const s of sections) {
|
|
||||||
for (const r of s.rows) {
|
|
||||||
m.set(r.key, { fr: r.overrideFr ?? r.baseFr, en: r.overrideEn ?? r.baseEn });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return m;
|
|
||||||
}, [sections]);
|
|
||||||
|
|
||||||
function onSubmit(formData: FormData) {
|
|
||||||
setError(null);
|
|
||||||
setSuccess(null);
|
|
||||||
startTransition(async () => {
|
|
||||||
const res = await saveHomeTranslationsAction(formData);
|
|
||||||
if (res.ok === false) {
|
|
||||||
setError(res.error);
|
|
||||||
} else {
|
|
||||||
const parts: string[] = [];
|
|
||||||
if (res.saved) parts.push(`${res.saved} sauvegardé${res.saved > 1 ? "s" : ""}`);
|
|
||||||
if (res.reset) parts.push(`${res.reset} réinitialisé${res.reset > 1 ? "s" : ""} (valeur de base)`);
|
|
||||||
setSuccess(parts.length > 0 ? parts.join(" · ") : "Aucun changement.");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// On crée un seul formulaire global qui contient toutes les sections.
|
|
||||||
let counter = 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form action={onSubmit} className="space-y-8">
|
|
||||||
<fieldset disabled={pending} className="space-y-8">
|
|
||||||
{sections.map((section) => (
|
|
||||||
<section key={section.id} className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
|
||||||
<header className="mb-3">
|
|
||||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-zinc-700">
|
|
||||||
{section.label}
|
|
||||||
</h2>
|
|
||||||
<p className="mt-0.5 text-xs text-zinc-500">{section.description}</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
{section.rows.map((r) => {
|
|
||||||
const idxFr = counter++;
|
|
||||||
const idxEn = counter++;
|
|
||||||
const init = initial.get(r.key)!;
|
|
||||||
const hasOverrideFr = r.overrideFr !== null;
|
|
||||||
const hasOverrideEn = r.overrideEn !== null;
|
|
||||||
return (
|
|
||||||
<div key={r.key} className="rounded-md border border-zinc-100 bg-zinc-50/50 p-3">
|
|
||||||
<div className="mb-2 flex flex-wrap items-baseline justify-between gap-2">
|
|
||||||
<code className="text-[11px] font-mono text-zinc-600">{r.key}</code>
|
|
||||||
<span className="flex gap-1 text-[10px] uppercase tracking-wider">
|
|
||||||
{hasOverrideFr ? (
|
|
||||||
<span className="rounded-full bg-emerald-100 px-1.5 py-0.5 font-semibold text-emerald-800 ring-1 ring-inset ring-emerald-300">
|
|
||||||
FR modifié
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
{hasOverrideEn ? (
|
|
||||||
<span className="rounded-full bg-emerald-100 px-1.5 py-0.5 font-semibold text-emerald-800 ring-1 ring-inset ring-emerald-300">
|
|
||||||
EN modifié
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
|
||||||
<label className="block">
|
|
||||||
<span className="mb-1 block text-[10px] font-semibold uppercase tracking-wider text-zinc-500">
|
|
||||||
FR
|
|
||||||
</span>
|
|
||||||
<input type="hidden" name={`entries[${idxFr}][key]`} value={r.key} />
|
|
||||||
<input type="hidden" name={`entries[${idxFr}][lang]`} value="fr" />
|
|
||||||
<input type="hidden" name={`entries[${idxFr}][baseValue]`} value={r.baseFr} />
|
|
||||||
<textarea
|
|
||||||
name={`entries[${idxFr}][value]`}
|
|
||||||
rows={autoRows(init.fr)}
|
|
||||||
defaultValue={init.fr}
|
|
||||||
maxLength={4000}
|
|
||||||
className="w-full rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm leading-relaxed focus:border-zinc-900 focus:outline-none"
|
|
||||||
/>
|
|
||||||
<span className="mt-0.5 block text-[10px] text-zinc-400">
|
|
||||||
Base : <span className="italic">{r.baseFr.slice(0, 80)}{r.baseFr.length > 80 ? "…" : ""}</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<label className="block">
|
|
||||||
<span className="mb-1 block text-[10px] font-semibold uppercase tracking-wider text-zinc-500">
|
|
||||||
EN
|
|
||||||
</span>
|
|
||||||
<input type="hidden" name={`entries[${idxEn}][key]`} value={r.key} />
|
|
||||||
<input type="hidden" name={`entries[${idxEn}][lang]`} value="en" />
|
|
||||||
<input type="hidden" name={`entries[${idxEn}][baseValue]`} value={r.baseEn} />
|
|
||||||
<textarea
|
|
||||||
name={`entries[${idxEn}][value]`}
|
|
||||||
rows={autoRows(init.en)}
|
|
||||||
defaultValue={init.en}
|
|
||||||
maxLength={4000}
|
|
||||||
className="w-full rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm leading-relaxed focus:border-zinc-900 focus:outline-none"
|
|
||||||
/>
|
|
||||||
<span className="mt-0.5 block text-[10px] text-zinc-400">
|
|
||||||
Base : <span className="italic">{r.baseEn.slice(0, 80)}{r.baseEn.length > 80 ? "…" : ""}</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<div className="rounded border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">{error}</div>
|
|
||||||
) : null}
|
|
||||||
{success ? (
|
|
||||||
<div className="rounded border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-800">{success}</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="sticky bottom-3 flex items-center justify-end gap-3 rounded-lg border border-zinc-200 bg-white px-4 py-3 shadow-md">
|
|
||||||
<span className="text-xs text-zinc-500">
|
|
||||||
Laisser une case vide ou identique au texte de base réinitialise l'override.
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="rounded-md bg-zinc-900 px-5 py-2 text-sm font-semibold text-white hover:bg-zinc-800 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{pending ? "Enregistrement…" : "Enregistrer les modifications"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
"use server";
|
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { auth } from "@/auth";
|
|
||||||
import { UserRole } from "@/generated/prisma/enums";
|
|
||||||
import { requireRole } from "@/lib/authorization";
|
|
||||||
import { recordAudit } from "@/lib/admin/audit";
|
|
||||||
import { deleteTranslationOverride, upsertTranslation } from "@/lib/admin/translations";
|
|
||||||
import { invalidateTranslationCache } from "@/lib/i18n/overrides";
|
|
||||||
import { isHomeKey } from "@/lib/admin/home-keys";
|
|
||||||
|
|
||||||
const entrySchema = z.object({
|
|
||||||
key: z.string().min(1).max(200),
|
|
||||||
lang: z.enum(["fr", "en"]),
|
|
||||||
value: z.string().max(4000),
|
|
||||||
baseValue: z.string().max(4000),
|
|
||||||
});
|
|
||||||
|
|
||||||
type SaveResult = { ok: true; saved: number; reset: number } | { ok: false; error: string };
|
|
||||||
|
|
||||||
export async function saveHomeTranslationsAction(fd: FormData): Promise<SaveResult> {
|
|
||||||
await requireRole([UserRole.ADMIN]);
|
|
||||||
const session = await auth();
|
|
||||||
const actorEmail = session?.user?.email ?? null;
|
|
||||||
|
|
||||||
// FormData arrive avec entries[N][key], entries[N][lang], entries[N][value], entries[N][baseValue].
|
|
||||||
const grouped = new Map<string, Record<string, string>>();
|
|
||||||
for (const [name, val] of fd.entries()) {
|
|
||||||
if (typeof val !== "string") continue;
|
|
||||||
const m = name.match(/^entries\[(\d+)\]\[(key|lang|value|baseValue)\]$/);
|
|
||||||
if (!m) continue;
|
|
||||||
const [, idx, field] = m;
|
|
||||||
if (!grouped.has(idx)) grouped.set(idx, {});
|
|
||||||
grouped.get(idx)![field] = val;
|
|
||||||
}
|
|
||||||
|
|
||||||
let saved = 0;
|
|
||||||
let reset = 0;
|
|
||||||
for (const raw of grouped.values()) {
|
|
||||||
const parsed = entrySchema.safeParse(raw);
|
|
||||||
if (!parsed.success) continue;
|
|
||||||
if (!isHomeKey(parsed.data.key)) continue;
|
|
||||||
|
|
||||||
const trimmed = parsed.data.value.trim();
|
|
||||||
const base = parsed.data.baseValue;
|
|
||||||
if (trimmed === "" || trimmed === base) {
|
|
||||||
// Suppression de l'override : on revient à la valeur du fichier.
|
|
||||||
await deleteTranslationOverride(parsed.data.key, parsed.data.lang);
|
|
||||||
reset++;
|
|
||||||
} else {
|
|
||||||
await upsertTranslation(parsed.data.key, parsed.data.lang, trimmed, actorEmail);
|
|
||||||
saved++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
invalidateTranslationCache();
|
|
||||||
await recordAudit({
|
|
||||||
scope: "admin.home",
|
|
||||||
event: "translations.save",
|
|
||||||
actorEmail,
|
|
||||||
details: { saved, reset },
|
|
||||||
});
|
|
||||||
revalidatePath("/admin/home");
|
|
||||||
revalidatePath("/");
|
|
||||||
return { ok: true, saved, reset };
|
|
||||||
}
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
import { HOME_SECTIONS } from "@/lib/admin/home-keys";
|
|
||||||
import { listTranslationsForKeys } from "@/lib/admin/translations";
|
|
||||||
import { HomeTranslationsForm } from "./_components/HomeTranslationsForm";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export default async function HomeAdminPage() {
|
|
||||||
const allKeys = await listTranslationsForKeys(HOME_SECTIONS.flatMap((s) => s.prefixes));
|
|
||||||
const keysBySection = HOME_SECTIONS.map((s) => ({
|
|
||||||
id: s.id,
|
|
||||||
label: s.label,
|
|
||||||
description: s.description,
|
|
||||||
rows: allKeys.filter((r) => s.prefixes.some((p) => r.key.startsWith(p))),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const totalOverrides = allKeys.reduce(
|
|
||||||
(acc, r) => acc + (r.overrideFr !== null ? 1 : 0) + (r.overrideEn !== null ? 1 : 0),
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto max-w-6xl">
|
|
||||||
<header className="mb-5 mt-2">
|
|
||||||
<h1 className="text-2xl font-semibold text-zinc-900">Page d'accueil</h1>
|
|
||||||
<p className="mt-1 text-sm text-zinc-600">
|
|
||||||
Édition des textes affichés sur la page d'accueil publique, en français et en anglais.
|
|
||||||
Les modifications sont appliquées immédiatement (cache rafraîchi sous 10 secondes).
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-xs text-zinc-500">
|
|
||||||
{totalOverrides === 0
|
|
||||||
? "Aucun texte personnalisé pour l'instant — les valeurs par défaut viennent des fichiers de traduction."
|
|
||||||
: `${totalOverrides} valeur${totalOverrides > 1 ? "s" : ""} personnalisée${totalOverrides > 1 ? "s" : ""} actuellement active${totalOverrides > 1 ? "s" : ""}.`}
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<HomeTranslationsForm sections={keysBySection} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,137 +0,0 @@
|
||||||
import Link from "next/link";
|
|
||||||
import { MediaType } from "@/generated/prisma/enums";
|
|
||||||
import { getMediaStats, listMediaAdmin } from "@/lib/admin/media";
|
|
||||||
import { StatusBadge } from "@/components/admin/StatusBadge";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
type PageProps = {
|
|
||||||
searchParams: Promise<{ q?: string; type?: string; carbetId?: string }>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const TYPE_VALUES = new Set<string>([MediaType.PHOTO, MediaType.VIDEO]);
|
|
||||||
|
|
||||||
export default async function MediaAdminPage({ searchParams }: PageProps) {
|
|
||||||
const sp = await searchParams;
|
|
||||||
const filters = {
|
|
||||||
q: sp.q?.trim() || undefined,
|
|
||||||
type: TYPE_VALUES.has(sp.type ?? "") ? (sp.type as MediaType) : undefined,
|
|
||||||
carbetId: sp.carbetId || undefined,
|
|
||||||
};
|
|
||||||
const [items, stats] = await Promise.all([listMediaAdmin(filters), getMediaStats()]);
|
|
||||||
const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short", year: "2-digit" });
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto max-w-7xl">
|
|
||||||
<header className="mb-5 mt-2">
|
|
||||||
<h1 className="text-2xl font-semibold text-zinc-900">Médias</h1>
|
|
||||||
<p className="mt-1 text-sm text-zinc-500">
|
|
||||||
{items.length} affiché{items.length > 1 ? "s" : ""}
|
|
||||||
{items.length === 200 ? " (limite atteinte — affinez les filtres)" : ""}
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-5">
|
|
||||||
<Stat label="Total fichiers" value={stats.total} />
|
|
||||||
<Stat label="Photos" value={stats.photo} />
|
|
||||||
<Stat label="Vidéos" value={stats.video} />
|
|
||||||
<Stat label="Carbets avec média" value={stats.carbetsWithMedia} />
|
|
||||||
<Stat label="Carbets sans média" value={stats.carbetsWithoutMedia} tone="warn" />
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<form className="mb-4 flex flex-wrap items-center gap-2 rounded-lg border border-zinc-200 bg-white p-3" method="get">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="q"
|
|
||||||
defaultValue={filters.q ?? ""}
|
|
||||||
placeholder="Recherche s3Key, carbet, slug…"
|
|
||||||
className="flex-1 min-w-[220px] rounded-md border border-zinc-300 px-3 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
|
|
||||||
/>
|
|
||||||
<select
|
|
||||||
name="type"
|
|
||||||
defaultValue={filters.type ?? ""}
|
|
||||||
className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
|
|
||||||
>
|
|
||||||
<option value="">Photos + vidéos</option>
|
|
||||||
<option value={MediaType.PHOTO}>Photos</option>
|
|
||||||
<option value={MediaType.VIDEO}>Vidéos</option>
|
|
||||||
</select>
|
|
||||||
<button type="submit" className="rounded-md bg-zinc-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-zinc-800">
|
|
||||||
Filtrer
|
|
||||||
</button>
|
|
||||||
{(filters.q || filters.type || filters.carbetId) ? (
|
|
||||||
<Link href="/admin/media" className="text-sm text-zinc-500 hover:text-zinc-900">
|
|
||||||
Réinit.
|
|
||||||
</Link>
|
|
||||||
) : null}
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{items.length === 0 ? (
|
|
||||||
<div className="rounded-lg border border-zinc-200 bg-white px-4 py-8 text-center text-sm text-zinc-500">
|
|
||||||
Aucun média ne correspond aux filtres.
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
|
||||||
{items.map((m) => (
|
|
||||||
<Link
|
|
||||||
key={m.id}
|
|
||||||
href={`/admin/carbets/${m.carbet.id}`}
|
|
||||||
className="group flex flex-col overflow-hidden rounded-lg border border-zinc-200 bg-white shadow-sm transition hover:shadow-md"
|
|
||||||
>
|
|
||||||
<div className="relative aspect-video bg-zinc-100">
|
|
||||||
{m.type === MediaType.PHOTO ? (
|
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img
|
|
||||||
src={m.s3Url}
|
|
||||||
alt={m.s3Key}
|
|
||||||
loading="lazy"
|
|
||||||
className="h-full w-full object-cover transition group-hover:scale-105"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex h-full w-full items-center justify-center text-3xl text-zinc-400">▶</div>
|
|
||||||
)}
|
|
||||||
<span className="absolute right-1 top-1 rounded bg-black/60 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-white">
|
|
||||||
{m.type}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1 p-2 text-xs">
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<span className="truncate font-semibold text-zinc-900">{m.carbet.title}</span>
|
|
||||||
<StatusBadge status={m.carbet.status} />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between gap-2 text-[10px] text-zinc-500">
|
|
||||||
<code className="truncate">{m.s3Key}</code>
|
|
||||||
<span className="whitespace-nowrap">{dateFmt.format(m.createdAt)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Stat({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
tone = "neutral",
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
value: number;
|
|
||||||
tone?: "neutral" | "warn";
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={
|
|
||||||
"rounded-lg border bg-white p-3 shadow-sm " +
|
|
||||||
(tone === "warn" && value > 0 ? "border-amber-300" : "border-zinc-200")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="text-[11px] uppercase tracking-wider text-zinc-500">{label}</div>
|
|
||||||
<div className={"mt-1 text-2xl font-semibold " + (tone === "warn" && value > 0 ? "text-amber-700" : "text-zinc-900")}>
|
|
||||||
{value}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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<string | null>(null);
|
|
||||||
|
|
||||||
function run() {
|
|
||||||
setError(null);
|
|
||||||
startTransition(async () => {
|
|
||||||
const res = await action();
|
|
||||||
if (res && (res as { ok?: boolean }).ok === false) {
|
|
||||||
setError((res as { error: string }).error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-end gap-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={run}
|
|
||||||
disabled={pending}
|
|
||||||
className="rounded-md bg-emerald-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-60"
|
|
||||||
>
|
|
||||||
{pending ? "Validation…" : "Valider l'organisation"}
|
|
||||||
</button>
|
|
||||||
{error ? <span className="text-xs text-rose-700">{error}</span> : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useTransition } from "react";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
action: () => Promise<{ ok: false; error: string } | { ok: true } | undefined | void>;
|
|
||||||
memberCount: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function DeleteOrgButton({ action, memberCount }: Props) {
|
|
||||||
const [pending, startTransition] = useTransition();
|
|
||||||
const [confirm, setConfirm] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
function run() {
|
|
||||||
setError(null);
|
|
||||||
startTransition(async () => {
|
|
||||||
const res = await action();
|
|
||||||
if (res && (res as { ok?: boolean }).ok === false) {
|
|
||||||
setError((res as { error: string }).error);
|
|
||||||
setConfirm(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (memberCount > 0) {
|
|
||||||
return (
|
|
||||||
<span className="rounded border border-zinc-200 bg-zinc-50 px-2 py-1 text-xs text-zinc-500">
|
|
||||||
Suppression impossible — {memberCount} membre{memberCount > 1 ? "s" : ""} rattaché{memberCount > 1 ? "s" : ""}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-end gap-1">
|
|
||||||
{confirm ? (
|
|
||||||
<div className="flex items-center gap-2 rounded border border-rose-300 bg-rose-50 px-2 py-1">
|
|
||||||
<span className="text-xs text-rose-900">Supprimer définitivement ?</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={run}
|
|
||||||
disabled={pending}
|
|
||||||
className="rounded bg-rose-700 px-2 py-1 text-[11px] font-semibold text-white hover:bg-rose-800 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
Oui, supprimer
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setConfirm(false)}
|
|
||||||
disabled={pending}
|
|
||||||
className="text-[11px] text-zinc-500 hover:text-zinc-900"
|
|
||||||
>
|
|
||||||
Annuler
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setConfirm(true)}
|
|
||||||
disabled={pending}
|
|
||||||
className="rounded-md border border-rose-300 bg-rose-50 px-3 py-1.5 text-xs font-semibold text-rose-700 hover:bg-rose-100 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
Supprimer l'organisation
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{error ? (
|
|
||||||
<div className="rounded border border-rose-200 bg-rose-50 px-2 py-1 text-xs text-rose-700">{error}</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,116 +0,0 @@
|
||||||
import { notFound } from "next/navigation";
|
|
||||||
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 { DeleteOrgButton } from "./_components/DeleteOrgButton";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
const ROLE_LABEL: Record<string, string> = {
|
|
||||||
OWNER: "Propriétaire",
|
|
||||||
CE_MANAGER: "CE — Manager",
|
|
||||||
CE_MEMBER: "CE — Membre",
|
|
||||||
TOURIST: "Touriste",
|
|
||||||
ADMIN: "Admin",
|
|
||||||
};
|
|
||||||
|
|
||||||
type PageProps = { params: Promise<{ id: string }> };
|
|
||||||
|
|
||||||
export default async function EditOrgPage({ params }: PageProps) {
|
|
||||||
const { id } = await params;
|
|
||||||
const org = await getOrganizationForAdmin(id);
|
|
||||||
if (!org) notFound();
|
|
||||||
|
|
||||||
const updateThis = async (fd: FormData) => {
|
|
||||||
"use server";
|
|
||||||
return await updateOrganizationAction(id, fd);
|
|
||||||
};
|
|
||||||
const deleteThis = async () => {
|
|
||||||
"use server";
|
|
||||||
return await deleteOrganizationAction(id);
|
|
||||||
};
|
|
||||||
const approveThis = async () => {
|
|
||||||
"use server";
|
|
||||||
return await approveOrganizationAction(id);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto max-w-5xl space-y-6">
|
|
||||||
<header className="mt-2 flex flex-wrap items-end justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<Link href="/admin/organizations" className="text-xs text-zinc-500 hover:text-zinc-900">
|
|
||||||
← Toutes les organisations
|
|
||||||
</Link>
|
|
||||||
<h1 className="mt-1 flex items-center gap-3 text-2xl font-semibold text-zinc-900">
|
|
||||||
{org.name}
|
|
||||||
{org.approved ? (
|
|
||||||
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-emerald-800 ring-1 ring-inset ring-emerald-300">
|
|
||||||
Validée
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-amber-800 ring-1 ring-inset ring-amber-300">
|
|
||||||
À valider
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</h1>
|
|
||||||
<p className="mt-1 text-sm text-zinc-500">
|
|
||||||
<code>/{org.slug}</code> · {org.members.length} membre{org.members.length > 1 ? "s" : ""} ·{" "}
|
|
||||||
{org._count.carbetMemberships} carbet{org._count.carbetMemberships > 1 ? "s" : ""} co-géré
|
|
||||||
{org._count.carbetMemberships > 1 ? "s" : ""} · {org._count.rentalProviders} provider rental
|
|
||||||
</p>
|
|
||||||
{org.contactEmail ? (
|
|
||||||
<p className="text-xs text-zinc-500">
|
|
||||||
Contact : <a href={`mailto:${org.contactEmail}`} className="underline">{org.contactEmail}</a>
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{!org.approved ? <ApproveOrgButton action={approveThis} /> : null}
|
|
||||||
<DeleteOrgButton action={deleteThis} memberCount={org.members.length} />
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
|
||||||
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-zinc-500">Identité</h2>
|
|
||||||
<OrgForm
|
|
||||||
action={updateThis}
|
|
||||||
submitLabel="Enregistrer les modifications"
|
|
||||||
initial={{ name: org.name, slug: org.slug, description: org.description }}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
|
||||||
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-zinc-500">
|
|
||||||
Membres ({org.members.length})
|
|
||||||
</h2>
|
|
||||||
{org.members.length === 0 ? (
|
|
||||||
<p className="text-sm text-zinc-500">
|
|
||||||
Aucun membre. Rattachez un utilisateur via{" "}
|
|
||||||
<Link href="/admin/users" className="text-zinc-900 hover:underline">
|
|
||||||
la page Utilisateurs
|
|
||||||
</Link>
|
|
||||||
.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<ul className="divide-y divide-zinc-100">
|
|
||||||
{org.members.map((m) => (
|
|
||||||
<li key={m.id} className="flex items-center justify-between gap-3 py-2 text-sm">
|
|
||||||
<Link href={`/admin/users/${m.id}`} className="text-zinc-900 hover:underline">
|
|
||||||
{m.firstName} {m.lastName}
|
|
||||||
<span className="ml-2 text-[11px] text-zinc-500">{m.email}</span>
|
|
||||||
</Link>
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<span className="text-xs text-zinc-600">{ROLE_LABEL[m.role] ?? m.role}</span>
|
|
||||||
<StatusBadge status={m.isActive ? "ACTIVE" : "INACTIVE"} />
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useTransition } from "react";
|
|
||||||
import { FormField, inputCls, textareaCls } from "@/components/admin/FormField";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
initial?: {
|
|
||||||
name?: string;
|
|
||||||
slug?: string;
|
|
||||||
description?: string | null;
|
|
||||||
};
|
|
||||||
action: (fd: FormData) => Promise<{ ok: false; error: string } | { ok: true } | undefined>;
|
|
||||||
submitLabel?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function OrgForm({ initial = {}, action, submitLabel = "Enregistrer" }: Props) {
|
|
||||||
const [pending, startTransition] = useTransition();
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [success, setSuccess] = useState<string | null>(null);
|
|
||||||
|
|
||||||
function onSubmit(formData: FormData) {
|
|
||||||
setError(null);
|
|
||||||
setSuccess(null);
|
|
||||||
startTransition(async () => {
|
|
||||||
const res = await action(formData);
|
|
||||||
if (res && res.ok === false) setError(res.error);
|
|
||||||
else if (res && res.ok === true) setSuccess("Organisation enregistrée.");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form action={onSubmit} className="space-y-4">
|
|
||||||
<fieldset disabled={pending} className="space-y-4">
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
||||||
<FormField label="Nom" required>
|
|
||||||
<input name="name" defaultValue={initial.name ?? ""} required maxLength={200} className={inputCls} />
|
|
||||||
</FormField>
|
|
||||||
<FormField label="Slug" required hint="URL : /organizations/<slug>">
|
|
||||||
<input
|
|
||||||
name="slug"
|
|
||||||
defaultValue={initial.slug ?? ""}
|
|
||||||
required
|
|
||||||
pattern="^[a-z0-9](?:[a-z0-9-]{0,80}[a-z0-9])?$"
|
|
||||||
placeholder="ex. ce-airbus-kourou"
|
|
||||||
className={inputCls}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
</div>
|
|
||||||
<FormField label="Description" hint="Brève présentation interne (max 5000 caractères).">
|
|
||||||
<textarea
|
|
||||||
name="description"
|
|
||||||
rows={5}
|
|
||||||
defaultValue={initial.description ?? ""}
|
|
||||||
maxLength={5000}
|
|
||||||
className={textareaCls}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<div className="rounded border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">{error}</div>
|
|
||||||
) : null}
|
|
||||||
{success ? (
|
|
||||||
<div className="rounded border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-800">{success}</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-2">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="rounded-md bg-zinc-900 px-5 py-2 text-sm font-semibold text-white hover:bg-zinc-800 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{pending ? "Enregistrement…" : submitLabel}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,123 +0,0 @@
|
||||||
"use server";
|
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
|
||||||
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";
|
|
||||||
|
|
||||||
async function audit(event: string, target: string, actor: string | null, details: Record<string, unknown>) {
|
|
||||||
await recordAudit({ scope: "admin.organizations", event, target, actorEmail: actor, details });
|
|
||||||
}
|
|
||||||
|
|
||||||
const slugRe = /^[a-z0-9](?:[a-z0-9-]{0,80}[a-z0-9])?$/;
|
|
||||||
|
|
||||||
const orgSchema = z.object({
|
|
||||||
name: z.string().trim().min(2).max(200),
|
|
||||||
slug: z.string().trim().regex(slugRe, "Slug invalide (a-z, 0-9, -)"),
|
|
||||||
description: z.string().trim().max(5000).optional().nullable(),
|
|
||||||
});
|
|
||||||
|
|
||||||
function parseFD(fd: FormData) {
|
|
||||||
return {
|
|
||||||
name: (fd.get("name") as string | null) ?? "",
|
|
||||||
slug: (fd.get("slug") as string | null) ?? "",
|
|
||||||
description: ((fd.get("description") as string | null) ?? "") || null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createOrganizationAction(fd: FormData) {
|
|
||||||
await requireRole([UserRole.ADMIN]);
|
|
||||||
const parsed = orgSchema.safeParse(parseFD(fd));
|
|
||||||
if (!parsed.success) {
|
|
||||||
return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
|
|
||||||
}
|
|
||||||
const session = await auth();
|
|
||||||
try {
|
|
||||||
const created = await prisma.organization.create({
|
|
||||||
data: { name: parsed.data.name, slug: parsed.data.slug, description: parsed.data.description ?? null },
|
|
||||||
});
|
|
||||||
await audit("organization.create", created.id, session?.user?.email ?? null, { slug: created.slug });
|
|
||||||
revalidatePath("/admin/organizations");
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof Error && e.message.includes("Unique")) {
|
|
||||||
return { ok: false as const, error: "Ce slug existe déjà." };
|
|
||||||
}
|
|
||||||
return { ok: false as const, error: "Erreur lors de la création." };
|
|
||||||
}
|
|
||||||
redirect("/admin/organizations");
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateOrganizationAction(id: string, fd: FormData) {
|
|
||||||
await requireRole([UserRole.ADMIN]);
|
|
||||||
const parsed = orgSchema.safeParse(parseFD(fd));
|
|
||||||
if (!parsed.success) {
|
|
||||||
return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
|
|
||||||
}
|
|
||||||
const session = await auth();
|
|
||||||
try {
|
|
||||||
await prisma.organization.update({
|
|
||||||
where: { id },
|
|
||||||
data: { name: parsed.data.name, slug: parsed.data.slug, description: parsed.data.description ?? null },
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof Error && e.message.includes("Unique")) {
|
|
||||||
return { ok: false as const, error: "Ce slug est déjà pris." };
|
|
||||||
}
|
|
||||||
return { ok: false as const, error: "Erreur lors de la mise à jour." };
|
|
||||||
}
|
|
||||||
await audit("organization.update", id, session?.user?.email ?? null, { slug: parsed.data.slug });
|
|
||||||
revalidatePath("/admin/organizations");
|
|
||||||
revalidatePath(`/admin/organizations/${id}`);
|
|
||||||
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();
|
|
||||||
const count = await prisma.user.count({ where: { organizationId: id } });
|
|
||||||
if (count > 0) {
|
|
||||||
return { ok: false as const, error: `Impossible : ${count} membre(s) encore rattaché(s).` };
|
|
||||||
}
|
|
||||||
await prisma.organization.delete({ where: { id } });
|
|
||||||
await audit("organization.delete", id, session?.user?.email ?? null, {});
|
|
||||||
revalidatePath("/admin/organizations");
|
|
||||||
redirect("/admin/organizations");
|
|
||||||
}
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
import Link from "next/link";
|
|
||||||
import { OrgForm } from "../_components/OrgForm";
|
|
||||||
import { createOrganizationAction } from "../actions";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export default function NewOrgPage() {
|
|
||||||
return (
|
|
||||||
<div className="mx-auto max-w-3xl space-y-6">
|
|
||||||
<header className="mt-2">
|
|
||||||
<Link href="/admin/organizations" className="text-xs text-zinc-500 hover:text-zinc-900">
|
|
||||||
← Toutes les organisations
|
|
||||||
</Link>
|
|
||||||
<h1 className="mt-1 text-2xl font-semibold text-zinc-900">Nouvelle organisation</h1>
|
|
||||||
</header>
|
|
||||||
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
|
||||||
<OrgForm action={createOrganizationAction} submitLabel="Créer l'organisation" />
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,140 +0,0 @@
|
||||||
import Link from "next/link";
|
|
||||||
import { countPendingOrganizations, listOrganizationsAdmin } from "@/lib/admin/organizations";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
type PageProps = {
|
|
||||||
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 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 (
|
|
||||||
<div className="mx-auto max-w-7xl">
|
|
||||||
<header className="mb-5 mt-2 flex items-end justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-semibold text-zinc-900">Organisations CE</h1>
|
|
||||||
<p className="mt-1 text-sm text-zinc-500">
|
|
||||||
{orgs.length} résultat{orgs.length > 1 ? "s" : ""}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Link
|
|
||||||
href="/admin/organizations/new"
|
|
||||||
className="inline-flex items-center gap-1.5 rounded-md bg-zinc-900 px-3 py-1.5 text-sm font-semibold text-white hover:bg-zinc-800"
|
|
||||||
>
|
|
||||||
+ Nouvelle organisation
|
|
||||||
</Link>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<nav className="mb-3 flex flex-wrap gap-2 text-sm">
|
|
||||||
{(
|
|
||||||
[
|
|
||||||
{ key: "all", label: "Toutes" },
|
|
||||||
{ key: "pending", label: pendingCount > 0 ? `À valider (${pendingCount})` : "À valider" },
|
|
||||||
{ key: "approved", label: "Validées" },
|
|
||||||
] as { key: StatusFilter; label: string }[]
|
|
||||||
).map((t) => {
|
|
||||||
const href = `/admin/organizations?status=${t.key}${filters.q ? `&q=${encodeURIComponent(filters.q)}` : ""}`;
|
|
||||||
const active = approved === t.key;
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={t.key}
|
|
||||||
href={href}
|
|
||||||
className={
|
|
||||||
"rounded-md px-3 py-1 font-medium " +
|
|
||||||
(active ? "bg-zinc-900 text-white" : "bg-zinc-100 text-zinc-700 hover:bg-zinc-200")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t.label}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<form className="mb-4 flex flex-wrap items-center gap-2 rounded-lg border border-zinc-200 bg-white p-3" method="get">
|
|
||||||
{approved !== "all" ? (
|
|
||||||
<input type="hidden" name="status" value={approved} />
|
|
||||||
) : null}
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="q"
|
|
||||||
defaultValue={filters.q ?? ""}
|
|
||||||
placeholder="Recherche nom, slug, description…"
|
|
||||||
className="flex-1 min-w-[220px] rounded-md border border-zinc-300 px-3 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
|
|
||||||
/>
|
|
||||||
<button type="submit" className="rounded-md bg-zinc-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-zinc-800">
|
|
||||||
Filtrer
|
|
||||||
</button>
|
|
||||||
{filters.q ? (
|
|
||||||
<Link href="/admin/organizations" className="text-sm text-zinc-500 hover:text-zinc-900">
|
|
||||||
Réinit.
|
|
||||||
</Link>
|
|
||||||
) : null}
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div className="overflow-hidden rounded-lg border border-zinc-200 bg-white">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead className="border-b border-zinc-200 bg-zinc-50 text-xs uppercase tracking-wider text-zinc-500">
|
|
||||||
<tr>
|
|
||||||
<th className="px-4 py-2 text-left font-semibold">Nom</th>
|
|
||||||
<th className="px-4 py-2 text-left font-semibold">Statut</th>
|
|
||||||
<th className="px-4 py-2 text-left font-semibold">Slug</th>
|
|
||||||
<th className="px-4 py-2 text-right font-semibold">Membres</th>
|
|
||||||
<th className="px-4 py-2 text-right font-semibold">Créée</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-zinc-100">
|
|
||||||
{orgs.length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={5} className="px-4 py-8 text-center text-sm text-zinc-500">
|
|
||||||
Aucune organisation.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : null}
|
|
||||||
{orgs.map((o) => (
|
|
||||||
<tr key={o.id} className="hover:bg-zinc-50">
|
|
||||||
<td className="px-4 py-2">
|
|
||||||
<Link href={`/admin/organizations/${o.id}`} className="font-medium text-zinc-900 hover:underline">
|
|
||||||
{o.name}
|
|
||||||
</Link>
|
|
||||||
{o.description ? (
|
|
||||||
<div className="text-[11px] text-zinc-500">{o.description.slice(0, 80)}{o.description.length > 80 ? "…" : ""}</div>
|
|
||||||
) : null}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2">
|
|
||||||
{o.approved ? (
|
|
||||||
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-emerald-800 ring-1 ring-inset ring-emerald-300">
|
|
||||||
Validée
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-amber-800 ring-1 ring-inset ring-amber-300">
|
|
||||||
À valider
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2 text-zinc-700"><code>/{o.slug}</code></td>
|
|
||||||
<td className="px-4 py-2 text-right font-mono text-zinc-700">{o.membersCount}</td>
|
|
||||||
<td className="px-4 py-2 text-right text-[11px] text-zinc-500">{dateFmt.format(o.createdAt)}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import Link from "next/link";
|
|
||||||
import { formatEur, getAdminKpis } from "@/lib/admin/kpis";
|
import { formatEur, getAdminKpis } from "@/lib/admin/kpis";
|
||||||
import { KPICard } from "@/components/admin/KPICard";
|
import { KPICard } from "@/components/admin/KPICard";
|
||||||
|
|
||||||
|
|
@ -67,34 +66,34 @@ export default async function AdminDashboard() {
|
||||||
</h2>
|
</h2>
|
||||||
<ul className="mt-3 grid grid-cols-1 gap-2 text-sm sm:grid-cols-2 lg:grid-cols-3">
|
<ul className="mt-3 grid grid-cols-1 gap-2 text-sm sm:grid-cols-2 lg:grid-cols-3">
|
||||||
<li>
|
<li>
|
||||||
<Link href="/admin/carbets" className="block rounded border border-zinc-200 px-3 py-2 hover:border-zinc-300 hover:bg-zinc-50">
|
<a href="/admin/carbets" className="block rounded border border-zinc-200 px-3 py-2 hover:border-zinc-300 hover:bg-zinc-50">
|
||||||
Gérer les carbets
|
Gérer les carbets
|
||||||
</Link>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<Link href="/admin/bookings" className="block rounded border border-zinc-200 px-3 py-2 hover:border-zinc-300 hover:bg-zinc-50">
|
<a href="/admin/bookings" className="block rounded border border-zinc-200 px-3 py-2 hover:border-zinc-300 hover:bg-zinc-50">
|
||||||
Voir les réservations
|
Voir les réservations
|
||||||
</Link>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<Link href="/admin/content-pages" className="block rounded border border-zinc-200 px-3 py-2 hover:border-zinc-300 hover:bg-zinc-50">
|
<a href="/admin/content-pages" className="block rounded border border-zinc-200 px-3 py-2 hover:border-zinc-300 hover:bg-zinc-50">
|
||||||
Éditer les pages
|
Éditer les pages
|
||||||
</Link>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<Link href="/admin/plugins" className="block rounded border border-zinc-200 px-3 py-2 hover:border-zinc-300 hover:bg-zinc-50">
|
<a href="/admin/plugins" className="block rounded border border-zinc-200 px-3 py-2 hover:border-zinc-300 hover:bg-zinc-50">
|
||||||
Activer / désactiver des plugins
|
Activer / désactiver des plugins
|
||||||
</Link>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<Link href="/admin/users" className="block rounded border border-zinc-200 px-3 py-2 hover:border-zinc-300 hover:bg-zinc-50">
|
<a href="/admin/users" className="block rounded border border-zinc-200 px-3 py-2 hover:border-zinc-300 hover:bg-zinc-50">
|
||||||
Modérer les utilisateurs
|
Modérer les utilisateurs
|
||||||
</Link>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<Link href="/admin/settings" className="block rounded border border-zinc-200 px-3 py-2 hover:border-zinc-300 hover:bg-zinc-50">
|
<a href="/admin/settings" className="block rounded border border-zinc-200 px-3 py-2 hover:border-zinc-300 hover:bg-zinc-50">
|
||||||
Paramètres
|
Paramètres
|
||||||
</Link>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -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<void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
function fmtEur(n: number): string {
|
|
||||||
return n.toLocaleString("fr-FR", { style: "currency", currency: "EUR" });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MarkPaidForm({ payout, markAction, unmarkAction }: Props) {
|
|
||||||
const router = useRouter();
|
|
||||||
const [pending, startTransition] = useTransition();
|
|
||||||
const [opened, setOpened] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
function onSubmit(fd: FormData) {
|
|
||||||
setError(null);
|
|
||||||
startTransition(async () => {
|
|
||||||
const res = await markAction(payout.providerId, payout.periodMonth.toISOString(), fd);
|
|
||||||
if (!res.ok) {
|
|
||||||
setError(res.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setOpened(false);
|
|
||||||
router.refresh();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function onUnmark() {
|
|
||||||
startTransition(async () => {
|
|
||||||
await unmarkAction(payout.providerId, payout.periodMonth.toISOString());
|
|
||||||
router.refresh();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (payout.paid) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-end gap-1 text-right">
|
|
||||||
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-emerald-800 ring-1 ring-inset ring-emerald-300">
|
|
||||||
Payé {fmtEur(payout.paid.amount)}
|
|
||||||
</span>
|
|
||||||
{payout.paid.reference ? (
|
|
||||||
<span className="font-mono text-[10px] text-zinc-500">Ref : {payout.paid.reference}</span>
|
|
||||||
) : null}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onUnmark}
|
|
||||||
disabled={pending}
|
|
||||||
className="text-[10px] text-zinc-500 hover:text-rose-700"
|
|
||||||
>
|
|
||||||
Annuler marquage
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (payout.netAmount <= 0) {
|
|
||||||
return <span className="text-[11px] text-zinc-400">—</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!opened) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setOpened(true)}
|
|
||||||
className="rounded-md bg-emerald-600 px-2.5 py-1 text-[11px] font-semibold text-white hover:bg-emerald-700"
|
|
||||||
>
|
|
||||||
Marquer payé
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form action={onSubmit} className="flex flex-col items-end gap-1 rounded-md border border-emerald-200 bg-emerald-50/50 p-2">
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
name="amount"
|
|
||||||
step="0.01"
|
|
||||||
min={0}
|
|
||||||
defaultValue={payout.netAmount.toFixed(2)}
|
|
||||||
className="w-24 rounded border border-zinc-300 px-1.5 py-0.5 text-[11px]"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="reference"
|
|
||||||
placeholder="Réf. virement"
|
|
||||||
maxLength={100}
|
|
||||||
className="w-32 rounded border border-zinc-300 px-1.5 py-0.5 text-[11px]"
|
|
||||||
/>
|
|
||||||
{error ? <span className="text-[10px] text-rose-700">{error}</span> : null}
|
|
||||||
<div className="flex gap-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setOpened(false);
|
|
||||||
setError(null);
|
|
||||||
}}
|
|
||||||
disabled={pending}
|
|
||||||
className="text-[10px] text-zinc-500 hover:text-zinc-900"
|
|
||||||
>
|
|
||||||
Annuler
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={pending}
|
|
||||||
className="rounded bg-emerald-600 px-2 py-0.5 text-[10px] font-semibold text-white hover:bg-emerald-700"
|
|
||||||
>
|
|
||||||
{pending ? "…" : "Confirmer"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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");
|
|
||||||
}
|
|
||||||
|
|
@ -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<number, typeof payouts>();
|
|
||||||
for (const p of payouts) {
|
|
||||||
const k = p.periodMonth.getTime();
|
|
||||||
if (!byMonth.has(k)) byMonth.set(k, []);
|
|
||||||
byMonth.get(k)!.push(p);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Globals
|
|
||||||
const totalDue = payouts
|
|
||||||
.filter((p) => !p.paid && p.netAmount > 0)
|
|
||||||
.reduce((s, p) => s + p.netAmount, 0);
|
|
||||||
const totalPaid = payouts
|
|
||||||
.filter((p) => p.paid)
|
|
||||||
.reduce((s, p) => s + (p.paid!.amount), 0);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto max-w-6xl space-y-6">
|
|
||||||
<header className="mt-2">
|
|
||||||
<h1 className="text-2xl font-semibold text-zinc-900">Reversements prestataires</h1>
|
|
||||||
<p className="mt-1 text-sm text-zinc-500">
|
|
||||||
Le marketplace encaisse centralisé sur System D ; voici les montants à reverser à chaque
|
|
||||||
prestataire pour les locations matériel des 6 derniers mois. System D n'apparaît pas
|
|
||||||
dans la liste (commission 0 %).
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
|
||||||
<KpiCard label="À payer" value={fmtEur(totalDue)} highlight />
|
|
||||||
<KpiCard label="Déjà payé" value={fmtEur(totalPaid)} />
|
|
||||||
<KpiCard label="Mois affichés" value={`${byMonth.size}`} />
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{Array.from(byMonth.entries())
|
|
||||||
.sort((a, b) => b[0] - a[0])
|
|
||||||
.map(([periodTs, rows]) => {
|
|
||||||
const period = new Date(periodTs);
|
|
||||||
const monthDue = rows
|
|
||||||
.filter((r) => !r.paid && r.netAmount > 0)
|
|
||||||
.reduce((s, r) => s + r.netAmount, 0);
|
|
||||||
return (
|
|
||||||
<section
|
|
||||||
key={periodTs}
|
|
||||||
className="overflow-hidden rounded-lg border border-zinc-200 bg-white shadow-sm"
|
|
||||||
>
|
|
||||||
<header className="flex items-baseline justify-between border-b border-zinc-100 px-4 py-2">
|
|
||||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-zinc-700">
|
|
||||||
{formatMonth(period)}
|
|
||||||
</h2>
|
|
||||||
<span className="text-xs text-zinc-500">
|
|
||||||
Reste à payer ce mois :{" "}
|
|
||||||
<span className="font-mono font-semibold text-zinc-900">{fmtEur(monthDue)}</span>
|
|
||||||
</span>
|
|
||||||
</header>
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead className="border-b border-zinc-100 bg-zinc-50 text-xs uppercase tracking-wider text-zinc-500">
|
|
||||||
<tr>
|
|
||||||
<th className="px-3 py-1.5 text-left font-semibold">Prestataire</th>
|
|
||||||
<th className="px-3 py-1.5 text-right font-semibold">Résa</th>
|
|
||||||
<th className="px-3 py-1.5 text-right font-semibold">CA brut</th>
|
|
||||||
<th className="px-3 py-1.5 text-right font-semibold">Commission</th>
|
|
||||||
<th className="px-3 py-1.5 text-right font-semibold">Net dû</th>
|
|
||||||
<th className="px-3 py-1.5 text-right font-semibold">Statut</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-zinc-100">
|
|
||||||
{rows
|
|
||||||
.sort((a, b) => b.netAmount - a.netAmount)
|
|
||||||
.map((p) => (
|
|
||||||
<tr key={`${p.providerId}-${periodTs}`} className="hover:bg-zinc-50">
|
|
||||||
<td className="px-3 py-1.5">
|
|
||||||
<Link
|
|
||||||
href={`/admin/rental-providers/${p.providerId}`}
|
|
||||||
className="text-zinc-900 hover:underline"
|
|
||||||
>
|
|
||||||
{p.providerName}
|
|
||||||
</Link>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-1.5 text-right font-mono text-zinc-700">
|
|
||||||
{p.bookingsCount}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-1.5 text-right font-mono text-zinc-700">
|
|
||||||
{fmtEur(p.grossAmount)}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-1.5 text-right font-mono text-zinc-700">
|
|
||||||
{fmtEur(p.commission)}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-1.5 text-right font-mono font-semibold text-zinc-900">
|
|
||||||
{fmtEur(p.netAmount)}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-1.5">
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<MarkPaidForm
|
|
||||||
payout={p}
|
|
||||||
markAction={markPayoutPaidAction}
|
|
||||||
unmarkAction={unmarkPayoutPaidAction}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function KpiCard({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
highlight,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
highlight?: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={
|
|
||||||
"rounded-lg border bg-white px-4 py-3 shadow-sm " +
|
|
||||||
(highlight ? "border-emerald-300 bg-emerald-50/40" : "border-zinc-200")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="text-[10px] uppercase tracking-wider text-zinc-500">{label}</div>
|
|
||||||
<div
|
|
||||||
className={
|
|
||||||
"mt-1 text-2xl font-semibold font-mono " +
|
|
||||||
(highlight ? "text-emerald-700" : "text-zinc-900")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{value}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,98 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useTransition } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
active: boolean;
|
|
||||||
carbetsCount: number;
|
|
||||||
toggleAction: (active: boolean) => Promise<{ ok: true } | { ok: false; error: string } | undefined>;
|
|
||||||
deleteAction: () => Promise<{ ok: true } | { ok: false; error: string } | undefined | void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ProviderInlineActions({ active, carbetsCount, toggleAction, deleteAction }: Props) {
|
|
||||||
const router = useRouter();
|
|
||||||
const [pending, startTransition] = useTransition();
|
|
||||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
function toggle() {
|
|
||||||
setError(null);
|
|
||||||
startTransition(async () => {
|
|
||||||
const res = await toggleAction(!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 (
|
|
||||||
<div className="flex flex-col items-end gap-2">
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={toggle}
|
|
||||||
disabled={pending}
|
|
||||||
className={
|
|
||||||
active
|
|
||||||
? "rounded-md border border-amber-300 bg-amber-50 px-3 py-1.5 text-xs font-semibold text-amber-800 hover:bg-amber-100 disabled:opacity-50"
|
|
||||||
: "rounded-md bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{active ? "Désactiver" : "Réactiver"}
|
|
||||||
</button>
|
|
||||||
{carbetsCount === 0 ? (
|
|
||||||
confirmDelete ? (
|
|
||||||
<div className="flex items-center gap-2 rounded border border-rose-300 bg-rose-50 px-2 py-1">
|
|
||||||
<span className="text-xs text-rose-900">Supprimer ?</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={del}
|
|
||||||
disabled={pending}
|
|
||||||
className="rounded bg-rose-700 px-2 py-1 text-[11px] font-semibold text-white hover:bg-rose-800 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
Oui, supprimer
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setConfirmDelete(false)}
|
|
||||||
disabled={pending}
|
|
||||||
className="text-[11px] text-zinc-500 hover:text-zinc-900"
|
|
||||||
>
|
|
||||||
Annuler
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setConfirmDelete(true)}
|
|
||||||
disabled={pending}
|
|
||||||
className="rounded-md border border-rose-300 bg-rose-50 px-3 py-1.5 text-xs font-semibold text-rose-700 hover:bg-rose-100 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
Supprimer
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<span className="rounded border border-zinc-200 bg-zinc-50 px-2 py-1 text-[11px] text-zinc-500">
|
|
||||||
Suppression impossible — {carbetsCount} carbet{carbetsCount > 1 ? "s" : ""} rattaché{carbetsCount > 1 ? "s" : ""}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{error ? (
|
|
||||||
<div className="rounded border border-rose-200 bg-rose-50 px-2 py-1 text-xs text-rose-700">{error}</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,105 +0,0 @@
|
||||||
import { notFound } from "next/navigation";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { getPirogueProviderForAdmin } from "@/lib/admin/pirogue-providers";
|
|
||||||
import { ProviderForm } from "../_components/ProviderForm";
|
|
||||||
import { StatusBadge } from "@/components/admin/StatusBadge";
|
|
||||||
import {
|
|
||||||
deletePirogueProviderAction,
|
|
||||||
togglePirogueActiveAction,
|
|
||||||
updatePirogueProviderAction,
|
|
||||||
} from "../actions";
|
|
||||||
import { ProviderInlineActions } from "./_components/ProviderInlineActions";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
type PageProps = { params: Promise<{ id: string }> };
|
|
||||||
|
|
||||||
export default async function EditPirogueProviderPage({ params }: PageProps) {
|
|
||||||
const { id } = await params;
|
|
||||||
const p = await getPirogueProviderForAdmin(id);
|
|
||||||
if (!p) notFound();
|
|
||||||
|
|
||||||
const updateThis = async (fd: FormData) => {
|
|
||||||
"use server";
|
|
||||||
return await updatePirogueProviderAction(id, fd);
|
|
||||||
};
|
|
||||||
const toggleThis = async (active: boolean) => {
|
|
||||||
"use server";
|
|
||||||
return await togglePirogueActiveAction(id, active);
|
|
||||||
};
|
|
||||||
const deleteThis = async () => {
|
|
||||||
"use server";
|
|
||||||
return await deletePirogueProviderAction(id);
|
|
||||||
};
|
|
||||||
|
|
||||||
const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short", year: "2-digit" });
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto max-w-5xl space-y-6">
|
|
||||||
<header className="mt-2 flex flex-wrap items-end justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<Link href="/admin/pirogue-providers" className="text-xs text-zinc-500 hover:text-zinc-900">
|
|
||||||
← Tous les prestataires
|
|
||||||
</Link>
|
|
||||||
<h1 className="mt-1 flex items-center gap-3 text-2xl font-semibold text-zinc-900">
|
|
||||||
{p.name}
|
|
||||||
<StatusBadge status={p.active ? "ACTIVE" : "INACTIVE"} />
|
|
||||||
</h1>
|
|
||||||
<p className="mt-1 text-sm text-zinc-500">
|
|
||||||
Fleuves : {p.rivers.length === 0 ? "—" : p.rivers.join(", ")} · {p.carbets.length} carbet
|
|
||||||
{p.carbets.length > 1 ? "s" : ""} référencé{p.carbets.length > 1 ? "s" : ""}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<ProviderInlineActions
|
|
||||||
active={p.active}
|
|
||||||
carbetsCount={p.carbets.length}
|
|
||||||
toggleAction={toggleThis}
|
|
||||||
deleteAction={deleteThis}
|
|
||||||
/>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
|
||||||
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-zinc-500">Informations</h2>
|
|
||||||
<ProviderForm
|
|
||||||
action={updateThis}
|
|
||||||
submitLabel="Enregistrer les modifications"
|
|
||||||
initial={{
|
|
||||||
name: p.name,
|
|
||||||
contactEmail: p.contactEmail,
|
|
||||||
contactPhone: p.contactPhone,
|
|
||||||
rivers: p.rivers,
|
|
||||||
pricingNote: p.pricingNote,
|
|
||||||
description: p.description,
|
|
||||||
active: p.active,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
|
||||||
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-zinc-500">
|
|
||||||
Carbets référencés ({p.carbets.length})
|
|
||||||
</h2>
|
|
||||||
{p.carbets.length === 0 ? (
|
|
||||||
<p className="text-sm text-zinc-500">Aucun carbet ne référence ce prestataire pour le moment.</p>
|
|
||||||
) : (
|
|
||||||
<ul className="divide-y divide-zinc-100">
|
|
||||||
{p.carbets.map((c) => (
|
|
||||||
<li key={c.id} className="flex items-center justify-between gap-3 py-2 text-sm">
|
|
||||||
<Link href={`/admin/carbets/${c.id}`} className="text-zinc-900 hover:underline">
|
|
||||||
{c.title}
|
|
||||||
<span className="ml-2 text-[11px] text-zinc-500">
|
|
||||||
<code>/{c.slug}</code> · {c.river}
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<StatusBadge status={c.status} />
|
|
||||||
<span className="text-[11px] text-zinc-500">{dateFmt.format(c.updatedAt)}</span>
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,119 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useTransition } from "react";
|
|
||||||
import { FormField, inputCls, textareaCls } from "@/components/admin/FormField";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
initial?: {
|
|
||||||
name?: string;
|
|
||||||
contactEmail?: string | null;
|
|
||||||
contactPhone?: string | null;
|
|
||||||
rivers?: string[];
|
|
||||||
pricingNote?: string | null;
|
|
||||||
description?: string | null;
|
|
||||||
active?: boolean;
|
|
||||||
};
|
|
||||||
action: (fd: FormData) => Promise<{ ok: false; error: string } | { ok: true } | undefined>;
|
|
||||||
submitLabel?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ProviderForm({ initial = {}, action, submitLabel = "Enregistrer" }: Props) {
|
|
||||||
const [pending, startTransition] = useTransition();
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [success, setSuccess] = useState<string | null>(null);
|
|
||||||
|
|
||||||
function onSubmit(formData: FormData) {
|
|
||||||
setError(null);
|
|
||||||
setSuccess(null);
|
|
||||||
startTransition(async () => {
|
|
||||||
const res = await action(formData);
|
|
||||||
if (res && res.ok === false) setError(res.error);
|
|
||||||
else if (res && res.ok === true) setSuccess("Prestataire enregistré.");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form action={onSubmit} className="space-y-4">
|
|
||||||
<fieldset disabled={pending} className="space-y-4">
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
||||||
<FormField label="Nom" required>
|
|
||||||
<input name="name" defaultValue={initial.name ?? ""} required maxLength={200} className={inputCls} />
|
|
||||||
</FormField>
|
|
||||||
<FormField label="Email de contact">
|
|
||||||
<input
|
|
||||||
name="contactEmail"
|
|
||||||
type="email"
|
|
||||||
defaultValue={initial.contactEmail ?? ""}
|
|
||||||
maxLength={200}
|
|
||||||
className={inputCls}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
<FormField label="Téléphone de contact">
|
|
||||||
<input
|
|
||||||
name="contactPhone"
|
|
||||||
defaultValue={initial.contactPhone ?? ""}
|
|
||||||
maxLength={50}
|
|
||||||
className={inputCls}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
<FormField label="Statut">
|
|
||||||
<label className="flex items-center gap-2 px-1 py-2 text-sm">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
name="active"
|
|
||||||
defaultChecked={initial.active ?? true}
|
|
||||||
className="h-4 w-4 rounded border-zinc-300"
|
|
||||||
/>
|
|
||||||
Prestataire actif (sélectionnable sur un carbet)
|
|
||||||
</label>
|
|
||||||
</FormField>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FormField label="Fleuves desservis" required hint="Séparer par virgule, point-virgule ou retour à la ligne.">
|
|
||||||
<input
|
|
||||||
name="rivers"
|
|
||||||
defaultValue={(initial.rivers ?? []).join(", ")}
|
|
||||||
placeholder="Maroni, Approuague, Oyapock"
|
|
||||||
className={inputCls}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<FormField label="Tarification" hint="Note libre — fourchette de prix, conditions, durées.">
|
|
||||||
<textarea
|
|
||||||
name="pricingNote"
|
|
||||||
rows={3}
|
|
||||||
defaultValue={initial.pricingNote ?? ""}
|
|
||||||
maxLength={2000}
|
|
||||||
className={textareaCls}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<FormField label="Description" hint="Présentation, langues parlées, prestations annexes.">
|
|
||||||
<textarea
|
|
||||||
name="description"
|
|
||||||
rows={4}
|
|
||||||
defaultValue={initial.description ?? ""}
|
|
||||||
maxLength={5000}
|
|
||||||
className={textareaCls}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<div className="rounded border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">{error}</div>
|
|
||||||
) : null}
|
|
||||||
{success ? (
|
|
||||||
<div className="rounded border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-800">{success}</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-2">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="rounded-md bg-zinc-900 px-5 py-2 text-sm font-semibold text-white hover:bg-zinc-800 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{pending ? "Enregistrement…" : submitLabel}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,95 +0,0 @@
|
||||||
"use server";
|
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { auth } from "@/auth";
|
|
||||||
import { UserRole } from "@/generated/prisma/enums";
|
|
||||||
import { requireRole } from "@/lib/authorization";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { recordAudit } from "@/lib/admin/audit";
|
|
||||||
|
|
||||||
async function audit(event: string, target: string, actor: string | null, details: Record<string, unknown>) {
|
|
||||||
await recordAudit({ scope: "admin.pirogue", event, target, actorEmail: actor, details });
|
|
||||||
}
|
|
||||||
|
|
||||||
const providerSchema = z.object({
|
|
||||||
name: z.string().trim().min(2).max(200),
|
|
||||||
contactEmail: z.string().trim().email().max(200).optional().nullable(),
|
|
||||||
contactPhone: z.string().trim().max(50).optional().nullable(),
|
|
||||||
rivers: z.array(z.string().trim().min(1).max(80)).max(20),
|
|
||||||
pricingNote: z.string().trim().max(2000).optional().nullable(),
|
|
||||||
description: z.string().trim().max(5000).optional().nullable(),
|
|
||||||
active: z.boolean(),
|
|
||||||
});
|
|
||||||
|
|
||||||
function parseFD(fd: FormData) {
|
|
||||||
const riversRaw = (fd.get("rivers") as string | null) ?? "";
|
|
||||||
const rivers = riversRaw
|
|
||||||
.split(/[,;\n]/)
|
|
||||||
.map((s) => s.trim())
|
|
||||||
.filter((s) => s.length > 0);
|
|
||||||
const get = (k: string) => {
|
|
||||||
const v = (fd.get(k) as string | null) ?? "";
|
|
||||||
return v.trim() === "" ? null : v.trim();
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
name: ((fd.get("name") as string | null) ?? "").trim(),
|
|
||||||
contactEmail: get("contactEmail"),
|
|
||||||
contactPhone: get("contactPhone"),
|
|
||||||
rivers,
|
|
||||||
pricingNote: get("pricingNote"),
|
|
||||||
description: get("description"),
|
|
||||||
active: fd.get("active") === "on",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createPirogueProviderAction(fd: FormData) {
|
|
||||||
await requireRole([UserRole.ADMIN]);
|
|
||||||
const parsed = providerSchema.safeParse(parseFD(fd));
|
|
||||||
if (!parsed.success) {
|
|
||||||
return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
|
|
||||||
}
|
|
||||||
const session = await auth();
|
|
||||||
const created = await prisma.pirogueProvider.create({ data: parsed.data });
|
|
||||||
await audit("pirogue.create", created.id, session?.user?.email ?? null, { name: created.name });
|
|
||||||
revalidatePath("/admin/pirogue-providers");
|
|
||||||
redirect(`/admin/pirogue-providers/${created.id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updatePirogueProviderAction(id: string, fd: FormData) {
|
|
||||||
await requireRole([UserRole.ADMIN]);
|
|
||||||
const parsed = providerSchema.safeParse(parseFD(fd));
|
|
||||||
if (!parsed.success) {
|
|
||||||
return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
|
|
||||||
}
|
|
||||||
const session = await auth();
|
|
||||||
await prisma.pirogueProvider.update({ where: { id }, data: parsed.data });
|
|
||||||
await audit("pirogue.update", id, session?.user?.email ?? null, { name: parsed.data.name, active: parsed.data.active });
|
|
||||||
revalidatePath("/admin/pirogue-providers");
|
|
||||||
revalidatePath(`/admin/pirogue-providers/${id}`);
|
|
||||||
return { ok: true as const };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function togglePirogueActiveAction(id: string, active: boolean) {
|
|
||||||
await requireRole([UserRole.ADMIN]);
|
|
||||||
const session = await auth();
|
|
||||||
await prisma.pirogueProvider.update({ where: { id }, data: { active } });
|
|
||||||
await audit("pirogue.active.update", id, session?.user?.email ?? null, { active });
|
|
||||||
revalidatePath("/admin/pirogue-providers");
|
|
||||||
revalidatePath(`/admin/pirogue-providers/${id}`);
|
|
||||||
return { ok: true as const };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deletePirogueProviderAction(id: string) {
|
|
||||||
await requireRole([UserRole.ADMIN]);
|
|
||||||
const session = await auth();
|
|
||||||
const count = await prisma.carbet.count({ where: { pirogueProviderId: id } });
|
|
||||||
if (count > 0) {
|
|
||||||
return { ok: false as const, error: `Impossible : ${count} carbet(s) référencent ce prestataire.` };
|
|
||||||
}
|
|
||||||
await prisma.pirogueProvider.delete({ where: { id } });
|
|
||||||
await audit("pirogue.delete", id, session?.user?.email ?? null, {});
|
|
||||||
revalidatePath("/admin/pirogue-providers");
|
|
||||||
redirect("/admin/pirogue-providers");
|
|
||||||
}
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
import Link from "next/link";
|
|
||||||
import { ProviderForm } from "../_components/ProviderForm";
|
|
||||||
import { createPirogueProviderAction } from "../actions";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export default function NewPirogueProviderPage() {
|
|
||||||
return (
|
|
||||||
<div className="mx-auto max-w-3xl space-y-6">
|
|
||||||
<header className="mt-2">
|
|
||||||
<Link href="/admin/pirogue-providers" className="text-xs text-zinc-500 hover:text-zinc-900">
|
|
||||||
← Tous les prestataires
|
|
||||||
</Link>
|
|
||||||
<h1 className="mt-1 text-2xl font-semibold text-zinc-900">Nouveau prestataire pirogue</h1>
|
|
||||||
</header>
|
|
||||||
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
|
||||||
<ProviderForm action={createPirogueProviderAction} submitLabel="Créer le prestataire" />
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,124 +0,0 @@
|
||||||
import Link from "next/link";
|
|
||||||
import { listPirogueProvidersAdmin, listPirogueRivers } from "@/lib/admin/pirogue-providers";
|
|
||||||
import { StatusBadge } from "@/components/admin/StatusBadge";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
type PageProps = {
|
|
||||||
searchParams: Promise<{
|
|
||||||
q?: string;
|
|
||||||
river?: string;
|
|
||||||
active?: string;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async function PirogueProvidersAdminPage({ searchParams }: PageProps) {
|
|
||||||
const sp = await searchParams;
|
|
||||||
const filters = {
|
|
||||||
q: sp.q?.trim() || undefined,
|
|
||||||
river: sp.river || undefined,
|
|
||||||
active: sp.active === "yes" || sp.active === "no" ? (sp.active as "yes" | "no") : undefined,
|
|
||||||
};
|
|
||||||
const [rows, rivers] = await Promise.all([listPirogueProvidersAdmin(filters), listPirogueRivers()]);
|
|
||||||
const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short", year: "2-digit" });
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto max-w-7xl">
|
|
||||||
<header className="mb-5 mt-2 flex items-end justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-semibold text-zinc-900">Prestataires pirogue</h1>
|
|
||||||
<p className="mt-1 text-sm text-zinc-500">
|
|
||||||
{rows.length} résultat{rows.length > 1 ? "s" : ""}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Link
|
|
||||||
href="/admin/pirogue-providers/new"
|
|
||||||
className="inline-flex items-center gap-1.5 rounded-md bg-zinc-900 px-3 py-1.5 text-sm font-semibold text-white hover:bg-zinc-800"
|
|
||||||
>
|
|
||||||
+ Nouveau prestataire
|
|
||||||
</Link>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<form className="mb-4 flex flex-wrap items-center gap-2 rounded-lg border border-zinc-200 bg-white p-3" method="get">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="q"
|
|
||||||
defaultValue={filters.q ?? ""}
|
|
||||||
placeholder="Recherche nom, email, description…"
|
|
||||||
className="flex-1 min-w-[220px] rounded-md border border-zinc-300 px-3 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
|
|
||||||
/>
|
|
||||||
<select
|
|
||||||
name="river"
|
|
||||||
defaultValue={filters.river ?? ""}
|
|
||||||
className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
|
|
||||||
>
|
|
||||||
<option value="">Tous fleuves</option>
|
|
||||||
{rivers.map((r) => (
|
|
||||||
<option key={r} value={r}>{r}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
name="active"
|
|
||||||
defaultValue={filters.active ?? ""}
|
|
||||||
className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
|
|
||||||
>
|
|
||||||
<option value="">Actifs + inactifs</option>
|
|
||||||
<option value="yes">Actifs</option>
|
|
||||||
<option value="no">Inactifs</option>
|
|
||||||
</select>
|
|
||||||
<button type="submit" className="rounded-md bg-zinc-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-zinc-800">
|
|
||||||
Filtrer
|
|
||||||
</button>
|
|
||||||
{(filters.q || filters.river || filters.active) ? (
|
|
||||||
<Link href="/admin/pirogue-providers" className="text-sm text-zinc-500 hover:text-zinc-900">
|
|
||||||
Réinit.
|
|
||||||
</Link>
|
|
||||||
) : null}
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div className="overflow-hidden rounded-lg border border-zinc-200 bg-white">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead className="border-b border-zinc-200 bg-zinc-50 text-xs uppercase tracking-wider text-zinc-500">
|
|
||||||
<tr>
|
|
||||||
<th className="px-4 py-2 text-left font-semibold">Nom</th>
|
|
||||||
<th className="px-4 py-2 text-left font-semibold">Fleuves</th>
|
|
||||||
<th className="px-4 py-2 text-left font-semibold">Contact</th>
|
|
||||||
<th className="px-4 py-2 text-right font-semibold">Carbets</th>
|
|
||||||
<th className="px-4 py-2 text-left font-semibold">État</th>
|
|
||||||
<th className="px-4 py-2 text-right font-semibold">MAJ</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-zinc-100">
|
|
||||||
{rows.length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={6} className="px-4 py-8 text-center text-sm text-zinc-500">
|
|
||||||
Aucun prestataire ne correspond aux filtres.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : null}
|
|
||||||
{rows.map((p) => (
|
|
||||||
<tr key={p.id} className="hover:bg-zinc-50">
|
|
||||||
<td className="px-4 py-2">
|
|
||||||
<Link href={`/admin/pirogue-providers/${p.id}`} className="font-medium text-zinc-900 hover:underline">
|
|
||||||
{p.name}
|
|
||||||
</Link>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2 text-zinc-700">
|
|
||||||
{p.rivers.length === 0 ? <span className="text-zinc-400">—</span> : p.rivers.join(", ")}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2 text-[11px] text-zinc-600">
|
|
||||||
{p.contactEmail ? <div>{p.contactEmail}</div> : null}
|
|
||||||
{p.contactPhone ? <div>{p.contactPhone}</div> : null}
|
|
||||||
{!p.contactEmail && !p.contactPhone ? <span className="text-zinc-400">—</span> : null}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2 text-right font-mono text-zinc-700">{p.carbetsCount}</td>
|
|
||||||
<td className="px-4 py-2"><StatusBadge status={p.active ? "ACTIVE" : "INACTIVE"} /></td>
|
|
||||||
<td className="px-4 py-2 text-right text-[11px] text-zinc-500">{dateFmt.format(p.updatedAt)}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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<string | null>(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 (
|
|
||||||
<div className="flex flex-col items-end gap-2">
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={toggle}
|
|
||||||
disabled={pending}
|
|
||||||
className={
|
|
||||||
active
|
|
||||||
? "rounded-md border border-amber-300 bg-amber-50 px-3 py-1.5 text-xs font-semibold text-amber-800 hover:bg-amber-100 disabled:opacity-50"
|
|
||||||
: "rounded-md bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{active ? "Désactiver" : "Réactiver"}
|
|
||||||
</button>
|
|
||||||
{confirmDelete ? (
|
|
||||||
<div className="flex items-center gap-2 rounded border border-rose-300 bg-rose-50 px-2 py-1">
|
|
||||||
<span className="text-xs text-rose-900">Supprimer ?</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={del}
|
|
||||||
disabled={pending}
|
|
||||||
className="rounded bg-rose-700 px-2 py-1 text-[11px] font-semibold text-white hover:bg-rose-800 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
Oui
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setConfirmDelete(false)}
|
|
||||||
disabled={pending}
|
|
||||||
className="text-[11px] text-zinc-500 hover:text-zinc-900"
|
|
||||||
>
|
|
||||||
Annuler
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setConfirmDelete(true)}
|
|
||||||
disabled={pending}
|
|
||||||
className="rounded-md border border-rose-300 bg-rose-50 px-3 py-1.5 text-xs font-semibold text-rose-700 hover:bg-rose-100 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
Supprimer
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{error ? <div className="rounded border border-rose-200 bg-rose-50 px-2 py-1 text-xs text-rose-700">{error}</div> : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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 (
|
|
||||||
<div className="mx-auto max-w-4xl space-y-6">
|
|
||||||
<header className="mt-2 flex flex-wrap items-end justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<Link href="/admin/rental-items" className="text-xs text-zinc-500 hover:text-zinc-900">
|
|
||||||
← Tous les items
|
|
||||||
</Link>
|
|
||||||
<h1 className="mt-1 flex items-center gap-3 text-2xl font-semibold text-zinc-900">
|
|
||||||
{item.name}
|
|
||||||
<StatusBadge status={item.active ? "ACTIVE" : "INACTIVE"} />
|
|
||||||
</h1>
|
|
||||||
<p className="mt-1 text-sm text-zinc-500">
|
|
||||||
{RENTAL_CATEGORY_LABEL[item.category]} ·{" "}
|
|
||||||
<Link href={`/admin/rental-providers/${item.provider.id}`} className="text-zinc-900 hover:underline">
|
|
||||||
{item.provider.name}
|
|
||||||
</Link>
|
|
||||||
{item.provider.isSystemD ? " (System D)" : ""}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<ItemInlineActions
|
|
||||||
active={item.active}
|
|
||||||
toggleActiveAction={toggleActiveThis}
|
|
||||||
deleteAction={deleteThis}
|
|
||||||
/>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
|
||||||
<h2 className="mb-3 text-base font-semibold text-zinc-900">Photos & vidéos</h2>
|
|
||||||
<MediaUploader
|
|
||||||
scope={{ kind: "rental-item", itemId: item.id }}
|
|
||||||
initialMedia={item.media}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
|
||||||
<ItemForm
|
|
||||||
providers={providers}
|
|
||||||
action={updateThis}
|
|
||||||
submitLabel="Enregistrer les modifications"
|
|
||||||
initial={{
|
|
||||||
providerId: item.providerId,
|
|
||||||
category: item.category,
|
|
||||||
name: item.name,
|
|
||||||
description: item.description,
|
|
||||||
imageUrl: item.imageUrl,
|
|
||||||
pricePerDay: item.pricePerDay.toString(),
|
|
||||||
pricePerWeek: item.pricePerWeek?.toString() ?? null,
|
|
||||||
deposit: item.deposit.toString(),
|
|
||||||
totalQty: item.totalQty,
|
|
||||||
withMotor: item.withMotor,
|
|
||||||
fuelIncluded: item.fuelIncluded,
|
|
||||||
requiresLicense: item.requiresLicense,
|
|
||||||
active: item.active,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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<string | null>(null);
|
|
||||||
const [success, setSuccess] = useState<string | null>(null);
|
|
||||||
|
|
||||||
function onSubmit(fd: FormData) {
|
|
||||||
setError(null);
|
|
||||||
setSuccess(null);
|
|
||||||
startTransition(async () => {
|
|
||||||
const res = await action(fd);
|
|
||||||
if (res && res.ok === false) setError(res.error);
|
|
||||||
else if (res && res.ok === true) setSuccess("Enregistré.");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form action={onSubmit} className="space-y-4">
|
|
||||||
<fieldset disabled={pending} className="space-y-4">
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
||||||
<FormField label="Prestataire" required>
|
|
||||||
<select name="providerId" defaultValue={initial.providerId ?? ""} required className={selectCls}>
|
|
||||||
<option value="" disabled>— sélectionner —</option>
|
|
||||||
{providers.map((p) => (
|
|
||||||
<option key={p.id} value={p.id}>
|
|
||||||
{p.name}{p.isSystemD ? " (System D)" : ""}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</FormField>
|
|
||||||
<FormField label="Catégorie" required>
|
|
||||||
<select name="category" defaultValue={initial.category ?? ""} required className={selectCls}>
|
|
||||||
<option value="" disabled>— sélectionner —</option>
|
|
||||||
{RENTAL_CATEGORIES.map((c) => (
|
|
||||||
<option key={c} value={c}>{RENTAL_CATEGORY_LABEL[c]}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</FormField>
|
|
||||||
<FormField label="Nom de l'item" required className="sm:col-span-2">
|
|
||||||
<input name="name" defaultValue={initial.name ?? ""} required maxLength={200} className={inputCls} placeholder="ex. Hamac coton large, Pirogue 5m avec moteur 15CV" />
|
|
||||||
</FormField>
|
|
||||||
<FormField label="Description" className="sm:col-span-2">
|
|
||||||
<textarea name="description" rows={3} defaultValue={initial.description ?? ""} maxLength={5000} className={textareaCls} />
|
|
||||||
</FormField>
|
|
||||||
<FormField label="URL image" hint="Optionnel, URL publique vers photo MinIO.">
|
|
||||||
<input name="imageUrl" type="url" defaultValue={initial.imageUrl ?? ""} maxLength={500} className={inputCls} />
|
|
||||||
</FormField>
|
|
||||||
<FormField label="Stock total (qté)" required>
|
|
||||||
<input name="totalQty" type="number" min={1} max={1000} defaultValue={initial.totalQty?.toString() ?? "1"} required className={inputCls} />
|
|
||||||
</FormField>
|
|
||||||
<FormField label="Prix / jour (€)" required>
|
|
||||||
<input name="pricePerDay" type="number" min={0} step="0.5" defaultValue={initial.pricePerDay?.toString() ?? ""} required className={inputCls} />
|
|
||||||
</FormField>
|
|
||||||
<FormField label="Prix / semaine (€)" hint="Optionnel — tarif dégressif sur 7+ jours.">
|
|
||||||
<input name="pricePerWeek" type="number" min={0} step="0.5" defaultValue={initial.pricePerWeek?.toString() ?? ""} className={inputCls} />
|
|
||||||
</FormField>
|
|
||||||
<FormField label="Caution (€)" hint="Dépôt de garantie (bloqué pendant la location).">
|
|
||||||
<input name="deposit" type="number" min={0} step="1" defaultValue={initial.deposit?.toString() ?? "0"} className={inputCls} />
|
|
||||||
</FormField>
|
|
||||||
<FormField label="Statut">
|
|
||||||
<label className="flex items-center gap-2 px-1 py-2 text-sm">
|
|
||||||
<input type="checkbox" name="active" defaultChecked={initial.active ?? true} className="h-4 w-4 rounded border-zinc-300" />
|
|
||||||
Actif (visible au catalogue)
|
|
||||||
</label>
|
|
||||||
</FormField>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<fieldset className="rounded-lg border border-zinc-200 bg-zinc-50 p-3">
|
|
||||||
<legend className="px-1 text-xs font-semibold uppercase tracking-wider text-zinc-500">
|
|
||||||
Spécifications navigation
|
|
||||||
</legend>
|
|
||||||
<div className="flex flex-wrap gap-4 pt-1 text-sm">
|
|
||||||
<label className="flex items-center gap-2">
|
|
||||||
<input type="checkbox" name="withMotor" defaultChecked={initial.withMotor ?? false} className="h-4 w-4 rounded border-zinc-300" />
|
|
||||||
Avec moteur
|
|
||||||
</label>
|
|
||||||
<label className="flex items-center gap-2">
|
|
||||||
<input type="checkbox" name="fuelIncluded" defaultChecked={initial.fuelIncluded ?? false} className="h-4 w-4 rounded border-zinc-300" />
|
|
||||||
Essence incluse
|
|
||||||
</label>
|
|
||||||
<label className="flex items-center gap-2">
|
|
||||||
<input type="checkbox" name="requiresLicense" defaultChecked={initial.requiresLicense ?? false} className="h-4 w-4 rounded border-zinc-300" />
|
|
||||||
Permis bateau requis
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<div className="rounded border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">{error}</div>
|
|
||||||
) : null}
|
|
||||||
{success ? (
|
|
||||||
<div className="rounded border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-800">{success}</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="flex items-center justify-end">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="rounded-md bg-zinc-900 px-5 py-2 text-sm font-semibold text-white hover:bg-zinc-800 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{pending ? "Enregistrement…" : submitLabel}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,129 +0,0 @@
|
||||||
"use server";
|
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
import { auth } from "@/auth";
|
|
||||||
import { RentalCategory, UserRole } from "@/generated/prisma/enums";
|
|
||||||
import { requireRole } from "@/lib/authorization";
|
|
||||||
import { recordAudit } from "@/lib/admin/audit";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
|
|
||||||
const itemSchema = z.object({
|
|
||||||
providerId: z.string().min(1),
|
|
||||||
category: z.enum([
|
|
||||||
RentalCategory.SLEEP,
|
|
||||||
RentalCategory.NAVIGATION,
|
|
||||||
RentalCategory.FISHING,
|
|
||||||
RentalCategory.COOKING,
|
|
||||||
RentalCategory.SAFETY,
|
|
||||||
]),
|
|
||||||
name: z.string().trim().min(2).max(200),
|
|
||||||
description: z.string().trim().max(5000).nullable().optional(),
|
|
||||||
imageUrl: z.string().trim().url().max(500).nullable().optional(),
|
|
||||||
pricePerDay: z.coerce.number().min(0).max(10000),
|
|
||||||
pricePerWeek: z.coerce.number().min(0).max(50000).nullable().optional(),
|
|
||||||
deposit: z.coerce.number().min(0).max(10000),
|
|
||||||
totalQty: z.coerce.number().int().min(1).max(1000),
|
|
||||||
withMotor: z.boolean(),
|
|
||||||
fuelIncluded: z.boolean(),
|
|
||||||
requiresLicense: z.boolean(),
|
|
||||||
active: z.boolean(),
|
|
||||||
});
|
|
||||||
|
|
||||||
function parseFD(fd: FormData) {
|
|
||||||
const get = (k: string) => {
|
|
||||||
const v = (fd.get(k) as string | null) ?? "";
|
|
||||||
return v.trim() === "" ? null : v.trim();
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
providerId: ((fd.get("providerId") as string | null) ?? "").trim(),
|
|
||||||
category: ((fd.get("category") as string | null) ?? "").trim(),
|
|
||||||
name: ((fd.get("name") as string | null) ?? "").trim(),
|
|
||||||
description: get("description"),
|
|
||||||
imageUrl: get("imageUrl"),
|
|
||||||
pricePerDay: fd.get("pricePerDay"),
|
|
||||||
pricePerWeek: get("pricePerWeek"),
|
|
||||||
deposit: fd.get("deposit") ?? "0",
|
|
||||||
totalQty: fd.get("totalQty") ?? "1",
|
|
||||||
withMotor: fd.get("withMotor") === "on",
|
|
||||||
fuelIncluded: fd.get("fuelIncluded") === "on",
|
|
||||||
requiresLicense: fd.get("requiresLicense") === "on",
|
|
||||||
active: fd.get("active") === "on",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createRentalItemAction(fd: FormData) {
|
|
||||||
await requireRole([UserRole.ADMIN]);
|
|
||||||
const parsed = itemSchema.safeParse(parseFD(fd));
|
|
||||||
if (!parsed.success) {
|
|
||||||
return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
|
|
||||||
}
|
|
||||||
const session = await auth();
|
|
||||||
const created = await prisma.rentalItem.create({ data: parsed.data });
|
|
||||||
await recordAudit({
|
|
||||||
scope: "admin.rental-items",
|
|
||||||
event: "create",
|
|
||||||
target: created.id,
|
|
||||||
actorEmail: session?.user?.email ?? null,
|
|
||||||
details: { name: created.name, providerId: created.providerId },
|
|
||||||
});
|
|
||||||
revalidatePath("/admin/rental-items");
|
|
||||||
redirect(`/admin/rental-items/${created.id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateRentalItemAction(id: string, fd: FormData) {
|
|
||||||
await requireRole([UserRole.ADMIN]);
|
|
||||||
const parsed = itemSchema.safeParse(parseFD(fd));
|
|
||||||
if (!parsed.success) {
|
|
||||||
return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
|
|
||||||
}
|
|
||||||
const session = await auth();
|
|
||||||
await prisma.rentalItem.update({ where: { id }, data: parsed.data });
|
|
||||||
await recordAudit({
|
|
||||||
scope: "admin.rental-items",
|
|
||||||
event: "update",
|
|
||||||
target: id,
|
|
||||||
actorEmail: session?.user?.email ?? null,
|
|
||||||
details: { name: parsed.data.name },
|
|
||||||
});
|
|
||||||
revalidatePath("/admin/rental-items");
|
|
||||||
revalidatePath(`/admin/rental-items/${id}`);
|
|
||||||
return { ok: true as const };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function toggleRentalItemActiveAction(id: string, active: boolean) {
|
|
||||||
await requireRole([UserRole.ADMIN]);
|
|
||||||
const session = await auth();
|
|
||||||
await prisma.rentalItem.update({ where: { id }, data: { active } });
|
|
||||||
await recordAudit({
|
|
||||||
scope: "admin.rental-items",
|
|
||||||
event: "active.update",
|
|
||||||
target: id,
|
|
||||||
actorEmail: session?.user?.email ?? null,
|
|
||||||
details: { active },
|
|
||||||
});
|
|
||||||
revalidatePath("/admin/rental-items");
|
|
||||||
revalidatePath(`/admin/rental-items/${id}`);
|
|
||||||
return { ok: true as const };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteRentalItemAction(id: string) {
|
|
||||||
await requireRole([UserRole.ADMIN]);
|
|
||||||
const session = await auth();
|
|
||||||
const linesCount = await prisma.rentalLine.count({ where: { itemId: id } });
|
|
||||||
if (linesCount > 0) {
|
|
||||||
return { ok: false as const, error: `Impossible : ${linesCount} ligne(s) de réservation pointe(nt) sur cet item.` };
|
|
||||||
}
|
|
||||||
await prisma.rentalItem.delete({ where: { id } });
|
|
||||||
await recordAudit({
|
|
||||||
scope: "admin.rental-items",
|
|
||||||
event: "delete",
|
|
||||||
target: id,
|
|
||||||
actorEmail: session?.user?.email ?? null,
|
|
||||||
details: {},
|
|
||||||
});
|
|
||||||
revalidatePath("/admin/rental-items");
|
|
||||||
redirect("/admin/rental-items");
|
|
||||||
}
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
import Link from "next/link";
|
|
||||||
import { ItemForm } from "../_components/ItemForm";
|
|
||||||
import { createRentalItemAction } from "../actions";
|
|
||||||
import { listProvidersForSelect } from "@/lib/admin/rental-items";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
type PageProps = { searchParams: Promise<{ providerId?: string }> };
|
|
||||||
|
|
||||||
export default async function NewRentalItemPage({ searchParams }: PageProps) {
|
|
||||||
const sp = await searchParams;
|
|
||||||
const providers = await listProvidersForSelect();
|
|
||||||
return (
|
|
||||||
<div className="mx-auto max-w-3xl space-y-6">
|
|
||||||
<header className="mt-2">
|
|
||||||
<Link href="/admin/rental-items" className="text-xs text-zinc-500 hover:text-zinc-900">
|
|
||||||
← Tous les items
|
|
||||||
</Link>
|
|
||||||
<h1 className="mt-1 text-2xl font-semibold text-zinc-900">Nouvel item locable</h1>
|
|
||||||
</header>
|
|
||||||
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
|
||||||
<ItemForm
|
|
||||||
providers={providers}
|
|
||||||
action={createRentalItemAction}
|
|
||||||
submitLabel="Créer l'item"
|
|
||||||
initial={{ providerId: sp.providerId, active: true, totalQty: 1 }}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,152 +0,0 @@
|
||||||
import Link from "next/link";
|
|
||||||
import { RentalCategory } from "@/generated/prisma/enums";
|
|
||||||
import {
|
|
||||||
RENTAL_CATEGORY_LABEL,
|
|
||||||
isRentalCategory,
|
|
||||||
listProvidersForSelect,
|
|
||||||
listRentalItemsAdmin,
|
|
||||||
} from "@/lib/admin/rental-items";
|
|
||||||
import { StatusBadge } from "@/components/admin/StatusBadge";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
type PageProps = {
|
|
||||||
searchParams: Promise<{
|
|
||||||
q?: string;
|
|
||||||
category?: string;
|
|
||||||
providerId?: string;
|
|
||||||
active?: string;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async function RentalItemsAdminPage({ searchParams }: PageProps) {
|
|
||||||
const sp = await searchParams;
|
|
||||||
const filters = {
|
|
||||||
q: sp.q?.trim() || undefined,
|
|
||||||
category: sp.category && isRentalCategory(sp.category) ? sp.category : undefined,
|
|
||||||
providerId: sp.providerId || undefined,
|
|
||||||
active: sp.active === "yes" || sp.active === "no" ? (sp.active as "yes" | "no") : undefined,
|
|
||||||
};
|
|
||||||
const [rows, providers] = await Promise.all([listRentalItemsAdmin(filters), listProvidersForSelect()]);
|
|
||||||
const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short", year: "2-digit" });
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto max-w-7xl">
|
|
||||||
<header className="mb-5 mt-2 flex items-end justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-semibold text-zinc-900">Catalogue d'items locables</h1>
|
|
||||||
<p className="mt-1 text-sm text-zinc-500">
|
|
||||||
{rows.length} item{rows.length > 1 ? "s" : ""}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Link
|
|
||||||
href="/admin/rental-items/new"
|
|
||||||
className="inline-flex items-center gap-1.5 rounded-md bg-zinc-900 px-3 py-1.5 text-sm font-semibold text-white hover:bg-zinc-800"
|
|
||||||
>
|
|
||||||
+ Nouvel item
|
|
||||||
</Link>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<form className="mb-4 flex flex-wrap items-center gap-2 rounded-lg border border-zinc-200 bg-white p-3" method="get">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="q"
|
|
||||||
defaultValue={filters.q ?? ""}
|
|
||||||
placeholder="Recherche nom, description…"
|
|
||||||
className="flex-1 min-w-[220px] rounded-md border border-zinc-300 px-3 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
|
|
||||||
/>
|
|
||||||
<select
|
|
||||||
name="category"
|
|
||||||
defaultValue={filters.category ?? ""}
|
|
||||||
className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
|
|
||||||
>
|
|
||||||
<option value="">Toutes catégories</option>
|
|
||||||
{Object.values(RentalCategory).map((c) => (
|
|
||||||
<option key={c} value={c}>{RENTAL_CATEGORY_LABEL[c]}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
name="providerId"
|
|
||||||
defaultValue={filters.providerId ?? ""}
|
|
||||||
className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
|
|
||||||
>
|
|
||||||
<option value="">Tous prestataires</option>
|
|
||||||
{providers.map((p) => (
|
|
||||||
<option key={p.id} value={p.id}>{p.name}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
name="active"
|
|
||||||
defaultValue={filters.active ?? ""}
|
|
||||||
className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
|
|
||||||
>
|
|
||||||
<option value="">Actifs + inactifs</option>
|
|
||||||
<option value="yes">Actifs</option>
|
|
||||||
<option value="no">Inactifs</option>
|
|
||||||
</select>
|
|
||||||
<button type="submit" className="rounded-md bg-zinc-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-zinc-800">
|
|
||||||
Filtrer
|
|
||||||
</button>
|
|
||||||
{(filters.q || filters.category || filters.providerId || filters.active) ? (
|
|
||||||
<Link href="/admin/rental-items" className="text-sm text-zinc-500 hover:text-zinc-900">Réinit.</Link>
|
|
||||||
) : null}
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div className="overflow-hidden rounded-lg border border-zinc-200 bg-white">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead className="border-b border-zinc-200 bg-zinc-50 text-xs uppercase tracking-wider text-zinc-500">
|
|
||||||
<tr>
|
|
||||||
<th className="px-4 py-2 text-left font-semibold">Nom</th>
|
|
||||||
<th className="px-4 py-2 text-left font-semibold">Catégorie</th>
|
|
||||||
<th className="px-4 py-2 text-left font-semibold">Prestataire</th>
|
|
||||||
<th className="px-4 py-2 text-right font-semibold">€ / jour</th>
|
|
||||||
<th className="px-4 py-2 text-right font-semibold">Stock</th>
|
|
||||||
<th className="px-4 py-2 text-right font-semibold">Caution</th>
|
|
||||||
<th className="px-4 py-2 text-left font-semibold">État</th>
|
|
||||||
<th className="px-4 py-2 text-right font-semibold">MAJ</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-zinc-100">
|
|
||||||
{rows.length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={8} className="px-4 py-8 text-center text-sm text-zinc-500">
|
|
||||||
Aucun item.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : null}
|
|
||||||
{rows.map((i) => (
|
|
||||||
<tr key={i.id} className="hover:bg-zinc-50">
|
|
||||||
<td className="px-4 py-2">
|
|
||||||
<Link href={`/admin/rental-items/${i.id}`} className="font-medium text-zinc-900 hover:underline">
|
|
||||||
{i.name}
|
|
||||||
</Link>
|
|
||||||
<div className="text-[11px] text-zinc-500">
|
|
||||||
{i.withMotor ? "⚙️ moteur · " : ""}
|
|
||||||
{i.requiresLicense ? "🪪 permis · " : ""}
|
|
||||||
{i.fuelIncluded ? "⛽ essence · " : ""}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2 text-zinc-700">{RENTAL_CATEGORY_LABEL[i.category]}</td>
|
|
||||||
<td className="px-4 py-2">
|
|
||||||
<Link href={`/admin/rental-providers/${i.providerId}`} className="text-zinc-900 hover:underline">
|
|
||||||
{i.providerName}
|
|
||||||
</Link>
|
|
||||||
{i.providerIsSystemD ? (
|
|
||||||
<span className="ml-1 rounded-full bg-emerald-100 px-1 py-0 text-[9px] font-semibold uppercase tracking-wider text-emerald-800 ring-1 ring-inset ring-emerald-300">
|
|
||||||
SD
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2 text-right font-mono text-zinc-700">{Number(i.pricePerDay).toFixed(0)}</td>
|
|
||||||
<td className="px-4 py-2 text-right font-mono text-zinc-700">{i.totalQty}</td>
|
|
||||||
<td className="px-4 py-2 text-right font-mono text-zinc-700">{Number(i.deposit).toFixed(0)}</td>
|
|
||||||
<td className="px-4 py-2"><StatusBadge status={i.active ? "ACTIVE" : "INACTIVE"} /></td>
|
|
||||||
<td className="px-4 py-2 text-right text-[11px] text-zinc-500">{dateFmt.format(i.updatedAt)}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,120 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useTransition } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
approved: boolean;
|
|
||||||
active: boolean;
|
|
||||||
itemsCount: number;
|
|
||||||
approveAction: () => Promise<{ ok: true } | { ok: false; error: string } | undefined>;
|
|
||||||
toggleActiveAction: (active: boolean) => Promise<{ ok: true } | { ok: false; error: string } | undefined>;
|
|
||||||
deleteAction: () => Promise<{ ok: true } | { ok: false; error: string } | undefined | void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ProviderInlineActions({
|
|
||||||
approved,
|
|
||||||
active,
|
|
||||||
itemsCount,
|
|
||||||
approveAction,
|
|
||||||
toggleActiveAction,
|
|
||||||
deleteAction,
|
|
||||||
}: Props) {
|
|
||||||
const router = useRouter();
|
|
||||||
const [pending, startTransition] = useTransition();
|
|
||||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
function approve() {
|
|
||||||
setError(null);
|
|
||||||
startTransition(async () => {
|
|
||||||
const res = await approveAction();
|
|
||||||
if (res && (res as { ok?: boolean }).ok === false) setError((res as { error: string }).error);
|
|
||||||
router.refresh();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
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 (
|
|
||||||
<div className="flex flex-col items-end gap-2">
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
{!approved ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={approve}
|
|
||||||
disabled={pending}
|
|
||||||
className="rounded-md bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
✓ Approuver
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={toggle}
|
|
||||||
disabled={pending}
|
|
||||||
className={
|
|
||||||
active
|
|
||||||
? "rounded-md border border-amber-300 bg-amber-50 px-3 py-1.5 text-xs font-semibold text-amber-800 hover:bg-amber-100 disabled:opacity-50"
|
|
||||||
: "rounded-md bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{active ? "Désactiver" : "Réactiver"}
|
|
||||||
</button>
|
|
||||||
{itemsCount === 0 ? (
|
|
||||||
confirmDelete ? (
|
|
||||||
<div className="flex items-center gap-2 rounded border border-rose-300 bg-rose-50 px-2 py-1">
|
|
||||||
<span className="text-xs text-rose-900">Supprimer ?</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={del}
|
|
||||||
disabled={pending}
|
|
||||||
className="rounded bg-rose-700 px-2 py-1 text-[11px] font-semibold text-white hover:bg-rose-800 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
Oui
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setConfirmDelete(false)}
|
|
||||||
disabled={pending}
|
|
||||||
className="text-[11px] text-zinc-500 hover:text-zinc-900"
|
|
||||||
>
|
|
||||||
Annuler
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setConfirmDelete(true)}
|
|
||||||
disabled={pending}
|
|
||||||
className="rounded-md border border-rose-300 bg-rose-50 px-3 py-1.5 text-xs font-semibold text-rose-700 hover:bg-rose-100 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
Supprimer
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<span className="rounded border border-zinc-200 bg-zinc-50 px-2 py-1 text-[11px] text-zinc-500">
|
|
||||||
{itemsCount} item(s) — supprimez-les d'abord
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{error ? <div className="rounded border border-rose-200 bg-rose-50 px-2 py-1 text-xs text-rose-700">{error}</div> : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,136 +0,0 @@
|
||||||
import { notFound } from "next/navigation";
|
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
import { StatusBadge } from "@/components/admin/StatusBadge";
|
|
||||||
import { getRentalProviderForAdmin } from "@/lib/admin/rental-providers";
|
|
||||||
import { RENTAL_CATEGORY_LABEL } from "@/lib/admin/rental-items";
|
|
||||||
|
|
||||||
import { ProviderForm } from "../_components/ProviderForm";
|
|
||||||
import { ProviderInlineActions } from "./_components/ProviderInlineActions";
|
|
||||||
import {
|
|
||||||
approveRentalProviderAction,
|
|
||||||
deleteRentalProviderAction,
|
|
||||||
toggleRentalProviderActiveAction,
|
|
||||||
updateRentalProviderAction,
|
|
||||||
} from "../actions";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
type PageProps = { params: Promise<{ id: string }> };
|
|
||||||
|
|
||||||
export default async function EditRentalProviderPage({ params }: PageProps) {
|
|
||||||
const { id } = await params;
|
|
||||||
const p = await getRentalProviderForAdmin(id);
|
|
||||||
if (!p) notFound();
|
|
||||||
|
|
||||||
const updateThis = async (fd: FormData) => {
|
|
||||||
"use server";
|
|
||||||
return await updateRentalProviderAction(id, fd);
|
|
||||||
};
|
|
||||||
const approveThis = async () => {
|
|
||||||
"use server";
|
|
||||||
return await approveRentalProviderAction(id);
|
|
||||||
};
|
|
||||||
const toggleActiveThis = async (active: boolean) => {
|
|
||||||
"use server";
|
|
||||||
return await toggleRentalProviderActiveAction(id, active);
|
|
||||||
};
|
|
||||||
const deleteThis = async () => {
|
|
||||||
"use server";
|
|
||||||
return await deleteRentalProviderAction(id);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto max-w-5xl space-y-6">
|
|
||||||
<header className="mt-2 flex flex-wrap items-end justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<Link href="/admin/rental-providers" className="text-xs text-zinc-500 hover:text-zinc-900">
|
|
||||||
← Tous les prestataires
|
|
||||||
</Link>
|
|
||||||
<h1 className="mt-1 flex items-center gap-3 text-2xl font-semibold text-zinc-900">
|
|
||||||
{p.name}
|
|
||||||
{p.isSystemD ? (
|
|
||||||
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-emerald-800 ring-1 ring-inset ring-emerald-300">
|
|
||||||
System D
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
<StatusBadge status={p.active ? "ACTIVE" : "INACTIVE"} />
|
|
||||||
{p.approved ? (
|
|
||||||
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-emerald-800 ring-1 ring-inset ring-emerald-300">
|
|
||||||
Approuvé
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-amber-800 ring-1 ring-inset ring-amber-300">
|
|
||||||
En attente
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</h1>
|
|
||||||
<p className="mt-1 text-sm text-zinc-500">
|
|
||||||
Fleuves : {p.rivers.join(", ") || "—"} · {p._count.items} item(s) · {p._count.rentalBookings} réservation(s) · Commission {Number(p.commissionPct).toFixed(1)}%
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<ProviderInlineActions
|
|
||||||
approved={p.approved}
|
|
||||||
active={p.active}
|
|
||||||
itemsCount={p._count.items}
|
|
||||||
approveAction={approveThis}
|
|
||||||
toggleActiveAction={toggleActiveThis}
|
|
||||||
deleteAction={deleteThis}
|
|
||||||
/>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
|
||||||
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-zinc-500">Informations</h2>
|
|
||||||
<ProviderForm
|
|
||||||
action={updateThis}
|
|
||||||
submitLabel="Enregistrer"
|
|
||||||
initial={{
|
|
||||||
name: p.name,
|
|
||||||
isSystemD: p.isSystemD,
|
|
||||||
contactEmail: p.contactEmail,
|
|
||||||
contactPhone: p.contactPhone,
|
|
||||||
rivers: p.rivers,
|
|
||||||
description: p.description,
|
|
||||||
commissionPct: p.commissionPct.toString(),
|
|
||||||
active: p.active,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
|
||||||
<h2 className="mb-4 flex items-center justify-between text-sm font-semibold uppercase tracking-wider text-zinc-500">
|
|
||||||
<span>Items ({p.items.length})</span>
|
|
||||||
<Link href={`/admin/rental-items?providerId=${p.id}`} className="text-xs normal-case tracking-normal text-zinc-700 underline hover:text-zinc-900">
|
|
||||||
Voir tous les items
|
|
||||||
</Link>
|
|
||||||
</h2>
|
|
||||||
{p.items.length === 0 ? (
|
|
||||||
<p className="text-sm text-zinc-500">
|
|
||||||
Pas encore d'item.{" "}
|
|
||||||
<Link href={`/admin/rental-items/new?providerId=${p.id}`} className="text-zinc-900 underline">
|
|
||||||
Créer un premier item
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<ul className="divide-y divide-zinc-100">
|
|
||||||
{p.items.map((i) => (
|
|
||||||
<li key={i.id} className="flex items-center justify-between gap-3 py-2 text-sm">
|
|
||||||
<Link href={`/admin/rental-items/${i.id}`} className="text-zinc-900 hover:underline">
|
|
||||||
{i.name}
|
|
||||||
<span className="ml-2 text-[11px] text-zinc-500">
|
|
||||||
{RENTAL_CATEGORY_LABEL[i.category]}
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
<span className="flex items-center gap-3">
|
|
||||||
<span className="font-mono text-xs text-zinc-700">{Number(i.pricePerDay).toFixed(0)} €/j</span>
|
|
||||||
<span className="text-[11px] text-zinc-500">qty {i.totalQty}</span>
|
|
||||||
<StatusBadge status={i.active ? "ACTIVE" : "INACTIVE"} />
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,132 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useTransition } from "react";
|
|
||||||
import { FormField, inputCls, textareaCls } from "@/components/admin/FormField";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
initial?: {
|
|
||||||
name?: string;
|
|
||||||
isSystemD?: boolean;
|
|
||||||
contactEmail?: string | null;
|
|
||||||
contactPhone?: string | null;
|
|
||||||
rivers?: string[];
|
|
||||||
description?: string | null;
|
|
||||||
commissionPct?: number | string;
|
|
||||||
active?: boolean;
|
|
||||||
};
|
|
||||||
action: (fd: FormData) => Promise<{ ok: false; error: string } | { ok: true } | undefined>;
|
|
||||||
submitLabel?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ProviderForm({ initial = {}, action, submitLabel = "Enregistrer" }: Props) {
|
|
||||||
const [pending, startTransition] = useTransition();
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [success, setSuccess] = useState<string | null>(null);
|
|
||||||
|
|
||||||
function onSubmit(fd: FormData) {
|
|
||||||
setError(null);
|
|
||||||
setSuccess(null);
|
|
||||||
startTransition(async () => {
|
|
||||||
const res = await action(fd);
|
|
||||||
if (res && res.ok === false) setError(res.error);
|
|
||||||
else if (res && res.ok === true) setSuccess("Enregistré.");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form action={onSubmit} className="space-y-4">
|
|
||||||
<fieldset disabled={pending} className="space-y-4">
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
||||||
<FormField label="Nom du prestataire" required>
|
|
||||||
<input name="name" defaultValue={initial.name ?? ""} required maxLength={200} className={inputCls} />
|
|
||||||
</FormField>
|
|
||||||
<FormField label="Type">
|
|
||||||
<label className="flex items-center gap-2 px-1 py-2 text-sm">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
name="isSystemD"
|
|
||||||
defaultChecked={initial.isSystemD ?? false}
|
|
||||||
className="h-4 w-4 rounded border-zinc-300"
|
|
||||||
/>
|
|
||||||
Fournisseur officiel System D (0 % commission)
|
|
||||||
</label>
|
|
||||||
</FormField>
|
|
||||||
<FormField label="Email contact">
|
|
||||||
<input
|
|
||||||
name="contactEmail"
|
|
||||||
type="email"
|
|
||||||
defaultValue={initial.contactEmail ?? ""}
|
|
||||||
maxLength={200}
|
|
||||||
className={inputCls}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
<FormField label="Téléphone contact">
|
|
||||||
<input
|
|
||||||
name="contactPhone"
|
|
||||||
defaultValue={initial.contactPhone ?? ""}
|
|
||||||
maxLength={50}
|
|
||||||
className={inputCls}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
<FormField label="Commission (%)" hint="0 pour System D, 5-15 % pour les prestataires externes.">
|
|
||||||
<input
|
|
||||||
name="commissionPct"
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
max={50}
|
|
||||||
step="0.5"
|
|
||||||
defaultValue={initial.commissionPct?.toString() ?? "10"}
|
|
||||||
className={inputCls}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
<FormField label="Statut">
|
|
||||||
<label className="flex items-center gap-2 px-1 py-2 text-sm">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
name="active"
|
|
||||||
defaultChecked={initial.active ?? true}
|
|
||||||
className="h-4 w-4 rounded border-zinc-300"
|
|
||||||
/>
|
|
||||||
Actif
|
|
||||||
</label>
|
|
||||||
</FormField>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FormField label="Fleuves desservis" required hint="Séparer par virgule, point-virgule ou retour à la ligne.">
|
|
||||||
<input
|
|
||||||
name="rivers"
|
|
||||||
defaultValue={(initial.rivers ?? []).join(", ")}
|
|
||||||
placeholder="Maroni, Approuague, Oyapock"
|
|
||||||
className={inputCls}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<FormField label="Description" hint="Présentation, points forts, conditions particulières.">
|
|
||||||
<textarea
|
|
||||||
name="description"
|
|
||||||
rows={4}
|
|
||||||
defaultValue={initial.description ?? ""}
|
|
||||||
maxLength={5000}
|
|
||||||
className={textareaCls}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<div className="rounded border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">{error}</div>
|
|
||||||
) : null}
|
|
||||||
{success ? (
|
|
||||||
<div className="rounded border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-800">{success}</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-2">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="rounded-md bg-zinc-900 px-5 py-2 text-sm font-semibold text-white hover:bg-zinc-800 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{pending ? "Enregistrement…" : submitLabel}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,150 +0,0 @@
|
||||||
"use server";
|
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
import { auth } from "@/auth";
|
|
||||||
import { UserRole } from "@/generated/prisma/enums";
|
|
||||||
import { requireRole } from "@/lib/authorization";
|
|
||||||
import { recordAudit } from "@/lib/admin/audit";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
|
|
||||||
const providerSchema = z.object({
|
|
||||||
name: z.string().trim().min(2).max(200),
|
|
||||||
isSystemD: z.boolean(),
|
|
||||||
managedByUserId: z.string().nullable().optional(),
|
|
||||||
contactEmail: z.string().trim().email().max(200).nullable().optional(),
|
|
||||||
contactPhone: z.string().trim().max(50).nullable().optional(),
|
|
||||||
rivers: z.array(z.string().trim().min(1).max(80)).max(20),
|
|
||||||
description: z.string().trim().max(5000).nullable().optional(),
|
|
||||||
commissionPct: z.coerce.number().min(0).max(50),
|
|
||||||
active: z.boolean(),
|
|
||||||
});
|
|
||||||
|
|
||||||
function parseFD(fd: FormData) {
|
|
||||||
const riversRaw = (fd.get("rivers") as string | null) ?? "";
|
|
||||||
const rivers = riversRaw
|
|
||||||
.split(/[,;\n]/)
|
|
||||||
.map((s) => s.trim())
|
|
||||||
.filter((s) => s.length > 0);
|
|
||||||
const get = (k: string) => {
|
|
||||||
const v = (fd.get(k) as string | null) ?? "";
|
|
||||||
return v.trim() === "" ? null : v.trim();
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
name: ((fd.get("name") as string | null) ?? "").trim(),
|
|
||||||
isSystemD: fd.get("isSystemD") === "on",
|
|
||||||
managedByUserId: get("managedByUserId"),
|
|
||||||
contactEmail: get("contactEmail"),
|
|
||||||
contactPhone: get("contactPhone"),
|
|
||||||
rivers,
|
|
||||||
description: get("description"),
|
|
||||||
commissionPct: fd.get("commissionPct"),
|
|
||||||
active: fd.get("active") === "on",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createRentalProviderAction(fd: FormData) {
|
|
||||||
await requireRole([UserRole.ADMIN]);
|
|
||||||
const parsed = providerSchema.safeParse(parseFD(fd));
|
|
||||||
if (!parsed.success) {
|
|
||||||
return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
|
|
||||||
}
|
|
||||||
const session = await auth();
|
|
||||||
const created = await prisma.rentalProvider.create({
|
|
||||||
data: {
|
|
||||||
...parsed.data,
|
|
||||||
approved: true, // créé par admin → approuvé d'office
|
|
||||||
approvedAt: new Date(),
|
|
||||||
approvedBy: session?.user?.email ?? null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await recordAudit({
|
|
||||||
scope: "admin.rental-providers",
|
|
||||||
event: "create",
|
|
||||||
target: created.id,
|
|
||||||
actorEmail: session?.user?.email ?? null,
|
|
||||||
details: { name: created.name, isSystemD: created.isSystemD },
|
|
||||||
});
|
|
||||||
revalidatePath("/admin/rental-providers");
|
|
||||||
redirect(`/admin/rental-providers/${created.id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateRentalProviderAction(id: string, fd: FormData) {
|
|
||||||
await requireRole([UserRole.ADMIN]);
|
|
||||||
const parsed = providerSchema.safeParse(parseFD(fd));
|
|
||||||
if (!parsed.success) {
|
|
||||||
return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
|
|
||||||
}
|
|
||||||
const session = await auth();
|
|
||||||
await prisma.rentalProvider.update({ where: { id }, data: parsed.data });
|
|
||||||
await recordAudit({
|
|
||||||
scope: "admin.rental-providers",
|
|
||||||
event: "update",
|
|
||||||
target: id,
|
|
||||||
actorEmail: session?.user?.email ?? null,
|
|
||||||
details: { name: parsed.data.name },
|
|
||||||
});
|
|
||||||
revalidatePath("/admin/rental-providers");
|
|
||||||
revalidatePath(`/admin/rental-providers/${id}`);
|
|
||||||
return { ok: true as const };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function approveRentalProviderAction(id: string) {
|
|
||||||
await requireRole([UserRole.ADMIN]);
|
|
||||||
const session = await auth();
|
|
||||||
await prisma.rentalProvider.update({
|
|
||||||
where: { id },
|
|
||||||
data: {
|
|
||||||
approved: true,
|
|
||||||
approvedAt: new Date(),
|
|
||||||
approvedBy: session?.user?.email ?? null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await recordAudit({
|
|
||||||
scope: "admin.rental-providers",
|
|
||||||
event: "approve",
|
|
||||||
target: id,
|
|
||||||
actorEmail: session?.user?.email ?? null,
|
|
||||||
details: {},
|
|
||||||
});
|
|
||||||
revalidatePath("/admin/rental-providers");
|
|
||||||
revalidatePath(`/admin/rental-providers/${id}`);
|
|
||||||
return { ok: true as const };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function toggleRentalProviderActiveAction(id: string, active: boolean) {
|
|
||||||
await requireRole([UserRole.ADMIN]);
|
|
||||||
const session = await auth();
|
|
||||||
await prisma.rentalProvider.update({ where: { id }, data: { active } });
|
|
||||||
await recordAudit({
|
|
||||||
scope: "admin.rental-providers",
|
|
||||||
event: "active.update",
|
|
||||||
target: id,
|
|
||||||
actorEmail: session?.user?.email ?? null,
|
|
||||||
details: { active },
|
|
||||||
});
|
|
||||||
revalidatePath("/admin/rental-providers");
|
|
||||||
revalidatePath(`/admin/rental-providers/${id}`);
|
|
||||||
return { ok: true as const };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteRentalProviderAction(id: string) {
|
|
||||||
await requireRole([UserRole.ADMIN]);
|
|
||||||
const session = await auth();
|
|
||||||
const itemsCount = await prisma.rentalItem.count({ where: { providerId: id } });
|
|
||||||
if (itemsCount > 0) {
|
|
||||||
return { ok: false as const, error: `Impossible : ${itemsCount} item(s) attaché(s).` };
|
|
||||||
}
|
|
||||||
await prisma.rentalProvider.delete({ where: { id } });
|
|
||||||
await recordAudit({
|
|
||||||
scope: "admin.rental-providers",
|
|
||||||
event: "delete",
|
|
||||||
target: id,
|
|
||||||
actorEmail: session?.user?.email ?? null,
|
|
||||||
details: {},
|
|
||||||
});
|
|
||||||
revalidatePath("/admin/rental-providers");
|
|
||||||
redirect("/admin/rental-providers");
|
|
||||||
}
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
import Link from "next/link";
|
|
||||||
import { ProviderForm } from "../_components/ProviderForm";
|
|
||||||
import { createRentalProviderAction } from "../actions";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export default function NewRentalProviderPage() {
|
|
||||||
return (
|
|
||||||
<div className="mx-auto max-w-3xl space-y-6">
|
|
||||||
<header className="mt-2">
|
|
||||||
<Link href="/admin/rental-providers" className="text-xs text-zinc-500 hover:text-zinc-900">
|
|
||||||
← Tous les prestataires
|
|
||||||
</Link>
|
|
||||||
<h1 className="mt-1 text-2xl font-semibold text-zinc-900">Nouveau prestataire location</h1>
|
|
||||||
</header>
|
|
||||||
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
|
||||||
<ProviderForm action={createRentalProviderAction} submitLabel="Créer le prestataire" />
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,149 +0,0 @@
|
||||||
import Link from "next/link";
|
|
||||||
import { listRentalProvidersAdmin, listRentalProviderRivers } from "@/lib/admin/rental-providers";
|
|
||||||
import { StatusBadge } from "@/components/admin/StatusBadge";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
type PageProps = {
|
|
||||||
searchParams: Promise<{
|
|
||||||
q?: string;
|
|
||||||
approved?: string;
|
|
||||||
active?: string;
|
|
||||||
river?: string;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async function RentalProvidersAdminPage({ searchParams }: PageProps) {
|
|
||||||
const sp = await searchParams;
|
|
||||||
const filters = {
|
|
||||||
q: sp.q?.trim() || undefined,
|
|
||||||
approved: sp.approved === "yes" || sp.approved === "no" ? (sp.approved as "yes" | "no") : undefined,
|
|
||||||
active: sp.active === "yes" || sp.active === "no" ? (sp.active as "yes" | "no") : undefined,
|
|
||||||
river: sp.river || undefined,
|
|
||||||
};
|
|
||||||
const [rows, rivers] = await Promise.all([listRentalProvidersAdmin(filters), listRentalProviderRivers()]);
|
|
||||||
const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short", year: "2-digit" });
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto max-w-7xl">
|
|
||||||
<header className="mb-5 mt-2 flex items-end justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-semibold text-zinc-900">Prestataires location matériel</h1>
|
|
||||||
<p className="mt-1 text-sm text-zinc-500">
|
|
||||||
{rows.length} résultat{rows.length > 1 ? "s" : ""}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Link
|
|
||||||
href="/admin/rental-providers/new"
|
|
||||||
className="inline-flex items-center gap-1.5 rounded-md bg-zinc-900 px-3 py-1.5 text-sm font-semibold text-white hover:bg-zinc-800"
|
|
||||||
>
|
|
||||||
+ Nouveau prestataire
|
|
||||||
</Link>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<form className="mb-4 flex flex-wrap items-center gap-2 rounded-lg border border-zinc-200 bg-white p-3" method="get">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="q"
|
|
||||||
defaultValue={filters.q ?? ""}
|
|
||||||
placeholder="Recherche nom, email, description…"
|
|
||||||
className="flex-1 min-w-[220px] rounded-md border border-zinc-300 px-3 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
|
|
||||||
/>
|
|
||||||
<select
|
|
||||||
name="approved"
|
|
||||||
defaultValue={filters.approved ?? ""}
|
|
||||||
className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
|
|
||||||
>
|
|
||||||
<option value="">Tous statuts approbation</option>
|
|
||||||
<option value="yes">Approuvés</option>
|
|
||||||
<option value="no">En attente</option>
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
name="active"
|
|
||||||
defaultValue={filters.active ?? ""}
|
|
||||||
className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
|
|
||||||
>
|
|
||||||
<option value="">Actifs + inactifs</option>
|
|
||||||
<option value="yes">Actifs</option>
|
|
||||||
<option value="no">Inactifs</option>
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
name="river"
|
|
||||||
defaultValue={filters.river ?? ""}
|
|
||||||
className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
|
|
||||||
>
|
|
||||||
<option value="">Tous fleuves</option>
|
|
||||||
{rivers.map((r) => (
|
|
||||||
<option key={r} value={r}>{r}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<button type="submit" className="rounded-md bg-zinc-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-zinc-800">
|
|
||||||
Filtrer
|
|
||||||
</button>
|
|
||||||
{(filters.q || filters.approved || filters.active || filters.river) ? (
|
|
||||||
<Link href="/admin/rental-providers" className="text-sm text-zinc-500 hover:text-zinc-900">
|
|
||||||
Réinit.
|
|
||||||
</Link>
|
|
||||||
) : null}
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div className="overflow-hidden rounded-lg border border-zinc-200 bg-white">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead className="border-b border-zinc-200 bg-zinc-50 text-xs uppercase tracking-wider text-zinc-500">
|
|
||||||
<tr>
|
|
||||||
<th className="px-4 py-2 text-left font-semibold">Nom</th>
|
|
||||||
<th className="px-4 py-2 text-left font-semibold">Fleuves</th>
|
|
||||||
<th className="px-4 py-2 text-right font-semibold">Items</th>
|
|
||||||
<th className="px-4 py-2 text-right font-semibold">Comm.</th>
|
|
||||||
<th className="px-4 py-2 text-left font-semibold">Approbation</th>
|
|
||||||
<th className="px-4 py-2 text-left font-semibold">État</th>
|
|
||||||
<th className="px-4 py-2 text-right font-semibold">MAJ</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-zinc-100">
|
|
||||||
{rows.length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={7} className="px-4 py-8 text-center text-sm text-zinc-500">
|
|
||||||
Aucun prestataire ne correspond aux filtres.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : null}
|
|
||||||
{rows.map((p) => (
|
|
||||||
<tr key={p.id} className="hover:bg-zinc-50">
|
|
||||||
<td className="px-4 py-2">
|
|
||||||
<Link href={`/admin/rental-providers/${p.id}`} className="font-medium text-zinc-900 hover:underline">
|
|
||||||
{p.name}
|
|
||||||
</Link>
|
|
||||||
{p.isSystemD ? (
|
|
||||||
<span className="ml-2 rounded-full bg-emerald-100 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-emerald-800 ring-1 ring-inset ring-emerald-300">
|
|
||||||
System D
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
<div className="text-[11px] text-zinc-500">{p.contactEmail ?? "—"}</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2 text-zinc-700">
|
|
||||||
{p.rivers.length === 0 ? <span className="text-zinc-400">—</span> : p.rivers.join(", ")}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2 text-right font-mono text-zinc-700">{p.itemsCount}</td>
|
|
||||||
<td className="px-4 py-2 text-right font-mono text-zinc-700">{Number(p.commissionPct).toFixed(1)}%</td>
|
|
||||||
<td className="px-4 py-2">
|
|
||||||
{p.approved ? (
|
|
||||||
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-emerald-800 ring-1 ring-inset ring-emerald-300">
|
|
||||||
Approuvé
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-amber-800 ring-1 ring-inset ring-amber-300">
|
|
||||||
En attente
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2"><StatusBadge status={p.active ? "ACTIVE" : "INACTIVE"} /></td>
|
|
||||||
<td className="px-4 py-2 text-right text-[11px] text-zinc-500">{dateFmt.format(p.updatedAt)}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,141 +0,0 @@
|
||||||
import Link from "next/link";
|
|
||||||
import { PaymentStatus, RentalBookingStatus } from "@/generated/prisma/enums";
|
|
||||||
import { listRentalBookingsAdmin, RENTAL_STATUS_LABEL } from "@/lib/admin/rental-bookings";
|
|
||||||
import { StatusBadge } from "@/components/admin/StatusBadge";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
type PageProps = {
|
|
||||||
searchParams: Promise<{
|
|
||||||
q?: string;
|
|
||||||
status?: string;
|
|
||||||
paymentStatus?: string;
|
|
||||||
providerId?: string;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const RENTAL_STATUS_VALUES = new Set<string>([
|
|
||||||
RentalBookingStatus.PENDING,
|
|
||||||
RentalBookingStatus.CONFIRMED,
|
|
||||||
RentalBookingStatus.HANDED_OVER,
|
|
||||||
RentalBookingStatus.RETURNED,
|
|
||||||
RentalBookingStatus.CANCELLED,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const PAYMENT_VALUES = new Set<string>([
|
|
||||||
PaymentStatus.PENDING,
|
|
||||||
PaymentStatus.AUTHORIZED,
|
|
||||||
PaymentStatus.SUCCEEDED,
|
|
||||||
PaymentStatus.FAILED,
|
|
||||||
PaymentStatus.REFUNDED,
|
|
||||||
]);
|
|
||||||
|
|
||||||
export default async function RentalsAdminPage({ searchParams }: PageProps) {
|
|
||||||
const sp = await searchParams;
|
|
||||||
const filters = {
|
|
||||||
q: sp.q?.trim() || undefined,
|
|
||||||
status: RENTAL_STATUS_VALUES.has(sp.status ?? "") ? (sp.status as RentalBookingStatus) : undefined,
|
|
||||||
paymentStatus: PAYMENT_VALUES.has(sp.paymentStatus ?? "") ? (sp.paymentStatus as PaymentStatus) : undefined,
|
|
||||||
providerId: sp.providerId || undefined,
|
|
||||||
};
|
|
||||||
const rows = await listRentalBookingsAdmin(filters);
|
|
||||||
const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short", year: "2-digit" });
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto max-w-7xl">
|
|
||||||
<header className="mb-5 mt-2">
|
|
||||||
<h1 className="text-2xl font-semibold text-zinc-900">Réservations matériel</h1>
|
|
||||||
<p className="mt-1 text-sm text-zinc-500">
|
|
||||||
{rows.length} résultat{rows.length > 1 ? "s" : ""}
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<form className="mb-4 flex flex-wrap items-center gap-2 rounded-lg border border-zinc-200 bg-white p-3" method="get">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="q"
|
|
||||||
defaultValue={filters.q ?? ""}
|
|
||||||
placeholder="Recherche ID, email locataire, prestataire…"
|
|
||||||
className="flex-1 min-w-[200px] rounded-md border border-zinc-300 px-3 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
|
|
||||||
/>
|
|
||||||
<select name="status" defaultValue={filters.status ?? ""} className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none">
|
|
||||||
<option value="">Tous statuts</option>
|
|
||||||
{Object.values(RentalBookingStatus).map((s) => (
|
|
||||||
<option key={s} value={s}>{RENTAL_STATUS_LABEL[s]}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<select name="paymentStatus" defaultValue={filters.paymentStatus ?? ""} className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none">
|
|
||||||
<option value="">Tous paiements</option>
|
|
||||||
{Object.values(PaymentStatus).map((s) => (
|
|
||||||
<option key={s} value={s}>{s}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<button type="submit" className="rounded-md bg-zinc-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-zinc-800">
|
|
||||||
Filtrer
|
|
||||||
</button>
|
|
||||||
{(filters.q || filters.status || filters.paymentStatus) ? (
|
|
||||||
<Link href="/admin/rentals" className="text-sm text-zinc-500 hover:text-zinc-900">Réinit.</Link>
|
|
||||||
) : null}
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div className="overflow-hidden rounded-lg border border-zinc-200 bg-white">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead className="border-b border-zinc-200 bg-zinc-50 text-xs uppercase tracking-wider text-zinc-500">
|
|
||||||
<tr>
|
|
||||||
<th className="px-4 py-2 text-left font-semibold">ID</th>
|
|
||||||
<th className="px-4 py-2 text-left font-semibold">Locataire</th>
|
|
||||||
<th className="px-4 py-2 text-left font-semibold">Prestataire</th>
|
|
||||||
<th className="px-4 py-2 text-left font-semibold">Items</th>
|
|
||||||
<th className="px-4 py-2 text-left font-semibold">Période</th>
|
|
||||||
<th className="px-4 py-2 text-right font-semibold">Montant</th>
|
|
||||||
<th className="px-4 py-2 text-left font-semibold">Statut</th>
|
|
||||||
<th className="px-4 py-2 text-left font-semibold">Paiement</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-zinc-100">
|
|
||||||
{rows.length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={8} className="px-4 py-8 text-center text-sm text-zinc-500">
|
|
||||||
Aucune réservation matériel.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : null}
|
|
||||||
{rows.map((r) => (
|
|
||||||
<tr key={r.id} className="hover:bg-zinc-50">
|
|
||||||
<td className="px-4 py-2 font-mono text-[11px] text-zinc-700">{r.id.slice(0, 10)}…</td>
|
|
||||||
<td className="px-4 py-2 text-zinc-700">
|
|
||||||
{r.tenant.firstName} {r.tenant.lastName}
|
|
||||||
<div className="text-[11px] text-zinc-500">{r.tenant.email}</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2">
|
|
||||||
<Link href={`/admin/rental-providers/${r.provider.id}`} className="text-zinc-900 hover:underline">
|
|
||||||
{r.provider.name}
|
|
||||||
</Link>
|
|
||||||
{r.provider.isSystemD ? <span className="ml-1 text-[9px] font-semibold text-emerald-700">SD</span> : null}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2 text-zinc-700">
|
|
||||||
{r.lines.length} ligne{r.lines.length > 1 ? "s" : ""}
|
|
||||||
<div className="text-[11px] text-zinc-500 truncate max-w-[200px]">
|
|
||||||
{r.lines.map((l) => `${l.qty}× ${l.item.name}`).join(", ")}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2 text-zinc-700">
|
|
||||||
{dateFmt.format(r.startDate)} → {dateFmt.format(r.endDate)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2 text-right font-mono text-zinc-900">
|
|
||||||
{Number(r.amount).toFixed(2)} {r.currency}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2">
|
|
||||||
<StatusBadge status={r.status} />
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2">
|
|
||||||
<StatusBadge status={r.paymentStatus} />
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -6,10 +6,9 @@ import { auth } from "@/auth";
|
||||||
import { UserRole } from "@/generated/prisma/enums";
|
import { UserRole } from "@/generated/prisma/enums";
|
||||||
import { requireRole } from "@/lib/authorization";
|
import { requireRole } from "@/lib/authorization";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { recordAudit } from "@/lib/admin/audit";
|
|
||||||
|
|
||||||
async function audit(event: string, target: string, actor: string | null, details: Record<string, unknown>) {
|
async function audit(event: string, target: string, actor: string | null, details: unknown) {
|
||||||
await recordAudit({ scope: "admin.reviews", event, target, actorEmail: actor, details });
|
console.log(JSON.stringify({ scope: "admin.reviews", event, target, actor, details, at: new Date().toISOString() }));
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateSchema = z.object({
|
const updateSchema = z.object({
|
||||||
|
|
|
||||||
|
|
@ -1,171 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useTransition } from "react";
|
|
||||||
import { FormField, inputCls, selectCls } from "@/components/admin/FormField";
|
|
||||||
import {
|
|
||||||
savePlatformSettingsAction,
|
|
||||||
saveStripeSettingsAction,
|
|
||||||
saveThemeSettingsAction,
|
|
||||||
} from "../actions";
|
|
||||||
|
|
||||||
type Action = (fd: FormData) => Promise<{ ok: false; error: string } | { ok: true } | undefined>;
|
|
||||||
|
|
||||||
function FormWrapper({
|
|
||||||
action,
|
|
||||||
children,
|
|
||||||
submitLabel = "Enregistrer",
|
|
||||||
}: {
|
|
||||||
action: Action;
|
|
||||||
children: React.ReactNode;
|
|
||||||
submitLabel?: string;
|
|
||||||
}) {
|
|
||||||
const [pending, startTransition] = useTransition();
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [success, setSuccess] = useState<string | null>(null);
|
|
||||||
|
|
||||||
function onSubmit(fd: FormData) {
|
|
||||||
setError(null);
|
|
||||||
setSuccess(null);
|
|
||||||
startTransition(async () => {
|
|
||||||
const res = await action(fd);
|
|
||||||
if (res && res.ok === false) setError(res.error);
|
|
||||||
else if (res && res.ok === true) setSuccess("Enregistré.");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form action={onSubmit} className="space-y-4">
|
|
||||||
<fieldset disabled={pending} className="space-y-4">
|
|
||||||
{children}
|
|
||||||
{error ? (
|
|
||||||
<div className="rounded border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">{error}</div>
|
|
||||||
) : null}
|
|
||||||
{success ? (
|
|
||||||
<div className="rounded border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-800">{success}</div>
|
|
||||||
) : null}
|
|
||||||
<div className="flex items-center justify-end">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="rounded-md bg-zinc-900 px-5 py-2 text-sm font-semibold text-white hover:bg-zinc-800 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{pending ? "Enregistrement…" : submitLabel}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PlatformForm({
|
|
||||||
initial,
|
|
||||||
}: {
|
|
||||||
initial: { name: string; defaultLang: string; activeLangs: string[]; currency: string; commissionPercent: number };
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<FormWrapper action={savePlatformSettingsAction}>
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
||||||
<FormField label="Nom de la plateforme" required>
|
|
||||||
<input name="name" defaultValue={initial.name} required maxLength={80} className={inputCls} />
|
|
||||||
</FormField>
|
|
||||||
<FormField label="Devise (ISO 4217)" required hint="EUR, USD, BRL…">
|
|
||||||
<input
|
|
||||||
name="currency"
|
|
||||||
defaultValue={initial.currency}
|
|
||||||
required
|
|
||||||
pattern="^[A-Z]{3}$"
|
|
||||||
maxLength={3}
|
|
||||||
className={inputCls + " uppercase"}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
<FormField label="Langue par défaut" required hint="Code ISO 639-1 (fr, en, pt…)">
|
|
||||||
<input
|
|
||||||
name="defaultLang"
|
|
||||||
defaultValue={initial.defaultLang}
|
|
||||||
required
|
|
||||||
pattern="^[a-zA-Z]{2}$"
|
|
||||||
maxLength={2}
|
|
||||||
className={inputCls + " lowercase"}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
<FormField label="Langues actives" required hint="Séparées par virgule (fr, en, pt).">
|
|
||||||
<input
|
|
||||||
name="activeLangs"
|
|
||||||
defaultValue={initial.activeLangs.join(", ")}
|
|
||||||
required
|
|
||||||
className={inputCls + " lowercase"}
|
|
||||||
placeholder="fr, en"
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
<FormField label="Commission plateforme (%)" hint="Affiché dans les CGV. 0 = pas de commission.">
|
|
||||||
<input
|
|
||||||
name="commissionPercent"
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
max={100}
|
|
||||||
step="0.01"
|
|
||||||
defaultValue={initial.commissionPercent.toString()}
|
|
||||||
className={inputCls}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
</div>
|
|
||||||
</FormWrapper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ThemeForm({ initial }: { initial: { active: string } }) {
|
|
||||||
return (
|
|
||||||
<FormWrapper action={saveThemeSettingsAction}>
|
|
||||||
<FormField label="Thème actif" hint="Détermine la skin du site public.">
|
|
||||||
<select name="active" defaultValue={initial.active} className={selectCls}>
|
|
||||||
<option value="default">default — sobre (admin-like)</option>
|
|
||||||
<option value="theme-aquarelle">theme-aquarelle — carnet naturaliste XIXᵉ</option>
|
|
||||||
<option value="theme-guyane">theme-guyane — palette tropicale</option>
|
|
||||||
</select>
|
|
||||||
</FormField>
|
|
||||||
</FormWrapper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function StripeForm({
|
|
||||||
initial,
|
|
||||||
}: {
|
|
||||||
initial: { currency: string; commissionMode: string; perBookingFeePercent: number };
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<FormWrapper action={saveStripeSettingsAction}>
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
||||||
<FormField label="Devise Stripe" required hint="Doit correspondre à la devise plateforme.">
|
|
||||||
<input
|
|
||||||
name="currency"
|
|
||||||
defaultValue={initial.currency}
|
|
||||||
required
|
|
||||||
pattern="^[A-Z]{3}$"
|
|
||||||
maxLength={3}
|
|
||||||
className={inputCls + " uppercase"}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
<FormField label="Modèle économique" required>
|
|
||||||
<select name="commissionMode" defaultValue={initial.commissionMode} className={selectCls}>
|
|
||||||
<option value="none">Aucune monétisation (preview)</option>
|
|
||||||
<option value="owner-subscription">Abonnement loueur (revenu plateforme)</option>
|
|
||||||
<option value="per-booking">Commission par réservation</option>
|
|
||||||
</select>
|
|
||||||
</FormField>
|
|
||||||
<FormField
|
|
||||||
label="Commission par réservation (%)"
|
|
||||||
hint="Utilisé uniquement si modèle = par réservation."
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
name="perBookingFeePercent"
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
max={100}
|
|
||||||
step="0.01"
|
|
||||||
defaultValue={initial.perBookingFeePercent.toString()}
|
|
||||||
className={inputCls}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
</div>
|
|
||||||
</FormWrapper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,100 +0,0 @@
|
||||||
"use server";
|
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { auth } from "@/auth";
|
|
||||||
import { UserRole } from "@/generated/prisma/enums";
|
|
||||||
import { requireRole } from "@/lib/authorization";
|
|
||||||
import { recordAudit } from "@/lib/admin/audit";
|
|
||||||
import { setSetting } from "@/lib/admin/settings";
|
|
||||||
import { togglePlugin } from "@/lib/plugins/server";
|
|
||||||
|
|
||||||
const platformSchema = z.object({
|
|
||||||
name: z.string().trim().min(2).max(80),
|
|
||||||
defaultLang: z.string().trim().length(2),
|
|
||||||
activeLangs: z.array(z.string().trim().length(2)).min(1).max(10),
|
|
||||||
currency: z.string().trim().length(3),
|
|
||||||
commissionPercent: z.coerce.number().min(0).max(100),
|
|
||||||
});
|
|
||||||
|
|
||||||
const themeSchema = z.object({
|
|
||||||
active: z.enum(["default", "theme-aquarelle", "theme-guyane"]),
|
|
||||||
});
|
|
||||||
|
|
||||||
const stripeSchema = z.object({
|
|
||||||
currency: z.string().trim().length(3),
|
|
||||||
commissionMode: z.enum(["none", "owner-subscription", "per-booking"]),
|
|
||||||
perBookingFeePercent: z.coerce.number().min(0).max(100),
|
|
||||||
});
|
|
||||||
|
|
||||||
async function actor() {
|
|
||||||
const session = await auth();
|
|
||||||
return session?.user?.email ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function savePlatformSettingsAction(fd: FormData) {
|
|
||||||
await requireRole([UserRole.ADMIN]);
|
|
||||||
const langsRaw = (fd.get("activeLangs") as string | null) ?? "";
|
|
||||||
const activeLangs = langsRaw
|
|
||||||
.split(/[,;\s]+/)
|
|
||||||
.map((s) => s.trim().toLowerCase())
|
|
||||||
.filter((s) => s.length === 2);
|
|
||||||
const parsed = platformSchema.safeParse({
|
|
||||||
name: fd.get("name"),
|
|
||||||
defaultLang: ((fd.get("defaultLang") as string | null) ?? "").toLowerCase(),
|
|
||||||
activeLangs,
|
|
||||||
currency: ((fd.get("currency") as string | null) ?? "").toUpperCase(),
|
|
||||||
commissionPercent: fd.get("commissionPercent"),
|
|
||||||
});
|
|
||||||
if (!parsed.success) {
|
|
||||||
return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
|
|
||||||
}
|
|
||||||
if (!parsed.data.activeLangs.includes(parsed.data.defaultLang)) {
|
|
||||||
return { ok: false as const, error: "La langue par défaut doit faire partie des langues actives." };
|
|
||||||
}
|
|
||||||
const who = await actor();
|
|
||||||
await setSetting("platform", parsed.data, who);
|
|
||||||
await recordAudit({ scope: "admin.settings", event: "platform.update", actorEmail: who, details: parsed.data });
|
|
||||||
revalidatePath("/admin/settings");
|
|
||||||
return { ok: true as const };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function saveThemeSettingsAction(fd: FormData) {
|
|
||||||
await requireRole([UserRole.ADMIN]);
|
|
||||||
const parsed = themeSchema.safeParse({ active: fd.get("active") });
|
|
||||||
if (!parsed.success) {
|
|
||||||
return { ok: false as const, error: "Thème invalide." };
|
|
||||||
}
|
|
||||||
const who = await actor();
|
|
||||||
await setSetting("theme", parsed.data, who);
|
|
||||||
|
|
||||||
// Le rendu du site public est piloté par l'état des plugins thème.
|
|
||||||
// On synchronise : un seul plugin actif (ou aucun pour "default").
|
|
||||||
const wantAquarelle = parsed.data.active === "theme-aquarelle";
|
|
||||||
const wantGuyane = parsed.data.active === "theme-guyane";
|
|
||||||
await togglePlugin("theme-aquarelle", wantAquarelle);
|
|
||||||
await togglePlugin("theme-guyane", wantGuyane);
|
|
||||||
|
|
||||||
await recordAudit({ scope: "admin.settings", event: "theme.update", actorEmail: who, details: parsed.data });
|
|
||||||
revalidatePath("/admin/settings");
|
|
||||||
revalidatePath("/admin/plugins");
|
|
||||||
revalidatePath("/");
|
|
||||||
return { ok: true as const };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function saveStripeSettingsAction(fd: FormData) {
|
|
||||||
await requireRole([UserRole.ADMIN]);
|
|
||||||
const parsed = stripeSchema.safeParse({
|
|
||||||
currency: ((fd.get("currency") as string | null) ?? "").toUpperCase(),
|
|
||||||
commissionMode: fd.get("commissionMode"),
|
|
||||||
perBookingFeePercent: fd.get("perBookingFeePercent"),
|
|
||||||
});
|
|
||||||
if (!parsed.success) {
|
|
||||||
return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
|
|
||||||
}
|
|
||||||
const who = await actor();
|
|
||||||
await setSetting("stripe", parsed.data, who);
|
|
||||||
await recordAudit({ scope: "admin.settings", event: "stripe.update", actorEmail: who, details: parsed.data });
|
|
||||||
revalidatePath("/admin/settings");
|
|
||||||
return { ok: true as const };
|
|
||||||
}
|
|
||||||
|
|
@ -1,100 +0,0 @@
|
||||||
import { getAllSettings, readEnvSnapshot } from "@/lib/admin/settings";
|
|
||||||
import { PlatformForm, StripeForm, ThemeForm } from "./_components/SettingsForms";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
function Badge({ ok, labelOk = "Configuré", labelKo = "Non configuré" }: { ok: boolean; labelOk?: string; labelKo?: string }) {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={
|
|
||||||
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wider ring-1 ring-inset " +
|
|
||||||
(ok ? "bg-emerald-100 text-emerald-800 ring-emerald-300" : "bg-amber-100 text-amber-800 ring-amber-300")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{ok ? labelOk : labelKo}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Row({ label, value }: { label: string; value: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-baseline justify-between gap-3 border-b border-zinc-100 py-1.5 last:border-b-0">
|
|
||||||
<dt className="text-[11px] uppercase tracking-wider text-zinc-500">{label}</dt>
|
|
||||||
<dd className="text-sm text-zinc-900">{value}</dd>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function SettingsAdminPage() {
|
|
||||||
const [settings, env] = await Promise.all([getAllSettings(), Promise.resolve(readEnvSnapshot())]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto max-w-5xl space-y-6">
|
|
||||||
<header className="mt-2">
|
|
||||||
<h1 className="text-2xl font-semibold text-zinc-900">Paramètres</h1>
|
|
||||||
<p className="mt-1 text-sm text-zinc-500">
|
|
||||||
Configuration plateforme persistée en base + snapshot des variables d'environnement (lecture seule).
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
|
||||||
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-zinc-500">Plateforme</h2>
|
|
||||||
<PlatformForm initial={settings.platform} />
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
|
||||||
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-zinc-500">Thème site public</h2>
|
|
||||||
<ThemeForm initial={settings.theme} />
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
|
||||||
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-zinc-500">Monétisation Stripe</h2>
|
|
||||||
<StripeForm initial={settings.stripe} />
|
|
||||||
<div className="mt-5 rounded border border-zinc-200 bg-zinc-50 p-3">
|
|
||||||
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wider text-zinc-500">
|
|
||||||
Variables d'environnement Stripe (lecture seule)
|
|
||||||
</h3>
|
|
||||||
<dl className="space-y-1.5">
|
|
||||||
<Row label="STRIPE_SECRET_KEY" value={<Badge ok={env.stripe.secretKeyConfigured} />} />
|
|
||||||
<Row label="STRIPE_PUBLISHABLE_KEY" value={<Badge ok={env.stripe.publishableKeyConfigured} />} />
|
|
||||||
<Row label="STRIPE_WEBHOOK_SECRET" value={<Badge ok={env.stripe.webhookSecretConfigured} />} />
|
|
||||||
<Row label="STRIPE_OWNER_SUBSCRIPTION_PRICE_ID" value={<Badge ok={env.stripe.ownerPriceIdConfigured} labelKo="Manquant ou placeholder" />} />
|
|
||||||
</dl>
|
|
||||||
<p className="mt-2 text-[11px] text-zinc-500">
|
|
||||||
Les clés et secrets restent dans les variables d'environnement du container. Modifications via le déploiement.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
|
||||||
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-zinc-500">Stockage médias (S3 / MinIO)</h2>
|
|
||||||
<dl className="space-y-1.5">
|
|
||||||
<Row label="Endpoint" value={<code className="text-xs">{env.s3.endpoint ?? "—"}</code>} />
|
|
||||||
<Row label="Région" value={<code className="text-xs">{env.s3.region ?? "—"}</code>} />
|
|
||||||
<Row label="Bucket" value={<code className="text-xs">{env.s3.bucket ?? "—"}</code>} />
|
|
||||||
<Row
|
|
||||||
label="URL publique"
|
|
||||||
value={
|
|
||||||
env.s3.publicUrl ? (
|
|
||||||
<a href={env.s3.publicUrl} target="_blank" rel="noreferrer" className="text-xs text-zinc-900 hover:underline">
|
|
||||||
{env.s3.publicUrl}
|
|
||||||
</a>
|
|
||||||
) : "—"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Row label="Path-style URL" value={<Badge ok={env.s3.pathStyle} labelOk="Activé" labelKo="Désactivé" />} />
|
|
||||||
<Row label="MINIO_ROOT_USER" value={<Badge ok={env.s3.rootUserConfigured} />} />
|
|
||||||
</dl>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
|
||||||
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-zinc-500">Déploiement</h2>
|
|
||||||
<dl className="space-y-1.5">
|
|
||||||
<Row label="URL publique" value={<code className="text-xs">{env.app.publicUrl ?? "—"}</code>} />
|
|
||||||
<Row label="URL auth" value={<code className="text-xs">{env.app.authUrl ?? "—"}</code>} />
|
|
||||||
<Row label="Version" value={<code className="text-xs">{env.app.deploymentVersion ?? "—"}</code>} />
|
|
||||||
</dl>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -5,7 +5,6 @@ import { auth } from "@/auth";
|
||||||
import { UserRole } from "@/generated/prisma/enums";
|
import { UserRole } from "@/generated/prisma/enums";
|
||||||
import { requireRole } from "@/lib/authorization";
|
import { requireRole } from "@/lib/authorization";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { recordAudit } from "@/lib/admin/audit";
|
|
||||||
|
|
||||||
const ROLE_VALUES = new Set<string>([
|
const ROLE_VALUES = new Set<string>([
|
||||||
UserRole.OWNER,
|
UserRole.OWNER,
|
||||||
|
|
@ -15,8 +14,8 @@ const ROLE_VALUES = new Set<string>([
|
||||||
UserRole.ADMIN,
|
UserRole.ADMIN,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
async function audit(event: string, target: string, actor: string | null, details: Record<string, unknown>) {
|
async function audit(event: string, target: string, actor: string | null, details: unknown) {
|
||||||
await recordAudit({ scope: "admin.users", event, target, actorEmail: actor, details });
|
console.log(JSON.stringify({ scope: "admin.users", event, target, actor, details, at: new Date().toISOString() }));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateUserRoleAction(id: string, role: string) {
|
export async function updateUserRoleAction(id: string, role: string) {
|
||||||
|
|
|
||||||
|
|
@ -11,28 +11,21 @@ const patchSchema = z.object({
|
||||||
published: z.boolean().optional(),
|
published: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
function normalizeLang(v: string | null): string {
|
|
||||||
if (!v) return "fr";
|
|
||||||
const l = v.toLowerCase().trim();
|
|
||||||
return /^[a-z]{2}$/.test(l) ? l : "fr";
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function PATCH(req: Request, ctx: { params: Promise<{ slug: string }> }) {
|
export async function PATCH(req: Request, ctx: { params: Promise<{ slug: string }> }) {
|
||||||
await requireRole([UserRole.ADMIN]);
|
await requireRole([UserRole.ADMIN]);
|
||||||
const { slug } = await ctx.params;
|
const { slug } = await ctx.params;
|
||||||
const url = new URL(req.url);
|
|
||||||
const lang = normalizeLang(url.searchParams.get("lang"));
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const parsed = patchSchema.safeParse(await req.json().catch(() => ({})));
|
const parsed = patchSchema.safeParse(await req.json().catch(() => ({})));
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return NextResponse.json({ error: "Invalid payload" }, { status: 400 });
|
return NextResponse.json({ error: "Invalid payload" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
// L'admin édite la version FR par défaut (édition multi-langues à venir).
|
||||||
const existing = await prisma.contentPage.findUnique({
|
const existing = await prisma.contentPage.findUnique({
|
||||||
where: { slug_lang: { slug, lang } },
|
where: { slug_lang: { slug, lang: "fr" } },
|
||||||
});
|
});
|
||||||
if (!existing) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
if (!existing) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
const updated = await prisma.contentPage.update({
|
const updated = await prisma.contentPage.update({
|
||||||
where: { slug_lang: { slug, lang } },
|
where: { slug_lang: { slug, lang: "fr" } },
|
||||||
data: {
|
data: {
|
||||||
...(parsed.data.title !== undefined ? { title: parsed.data.title } : {}),
|
...(parsed.data.title !== undefined ? { title: parsed.data.title } : {}),
|
||||||
...(parsed.data.body !== undefined ? { body: parsed.data.body } : {}),
|
...(parsed.data.body !== undefined ? { body: parsed.data.body } : {}),
|
||||||
|
|
@ -42,7 +35,6 @@ export async function PATCH(req: Request, ctx: { params: Promise<{ slug: string
|
||||||
});
|
});
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
slug: updated.slug,
|
slug: updated.slug,
|
||||||
lang: updated.lang,
|
|
||||||
title: updated.title,
|
title: updated.title,
|
||||||
published: updated.published,
|
published: updated.published,
|
||||||
updatedAt: updated.updatedAt,
|
updatedAt: updated.updatedAt,
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,6 @@ import {
|
||||||
parseIsoDate,
|
parseIsoDate,
|
||||||
} from "@/lib/booking";
|
} from "@/lib/booking";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { sendBookingRequestToOwner, sendBookingRequestToTenant } from "@/lib/email";
|
|
||||||
import { rateLimitRequest } from "@/lib/rate-limit";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
|
@ -29,14 +27,6 @@ type CreateBookingBody = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
const rl = rateLimitRequest(request, "bookings", 60 * 60 * 1000, 10);
|
|
||||||
if (!rl.ok) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: `Trop de tentatives. Réessayez dans ${rl.retryAfter}s.` },
|
|
||||||
{ status: 429, headers: { "Retry-After": String(rl.retryAfter) } },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return NextResponse.json({ error: "Non authentifié." }, { status: 401 });
|
return NextResponse.json({ error: "Non authentifié." }, { status: 401 });
|
||||||
|
|
@ -88,9 +78,6 @@ export async function POST(request: Request) {
|
||||||
ownerId: true,
|
ownerId: true,
|
||||||
capacity: true,
|
capacity: true,
|
||||||
status: true,
|
status: true,
|
||||||
nightlyPrice: true,
|
|
||||||
title: true,
|
|
||||||
owner: { select: { email: true, firstName: true } },
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -196,12 +183,6 @@ export async function POST(request: Request) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const nights = Math.max(
|
|
||||||
1,
|
|
||||||
Math.round((endDate.getTime() - startDate.getTime()) / 86400000),
|
|
||||||
);
|
|
||||||
const computedAmount = Number(carbet.nightlyPrice) * nights;
|
|
||||||
|
|
||||||
const booking = await prisma.booking.create({
|
const booking = await prisma.booking.create({
|
||||||
data: {
|
data: {
|
||||||
carbetId: carbet.id,
|
carbetId: carbet.id,
|
||||||
|
|
@ -210,7 +191,7 @@ export async function POST(request: Request) {
|
||||||
endDate,
|
endDate,
|
||||||
guestCount,
|
guestCount,
|
||||||
status: BookingStatus.PENDING,
|
status: BookingStatus.PENDING,
|
||||||
amount: computedAmount.toFixed(2),
|
amount: 0,
|
||||||
currency: "EUR",
|
currency: "EUR",
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
|
|
@ -226,34 +207,5 @@ export async function POST(request: Request) {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Best-effort emails (n'échouent pas la réservation si Resend down).
|
|
||||||
const tenant = await prisma.user.findUnique({
|
|
||||||
where: { id: session.user.id },
|
|
||||||
select: { email: true, firstName: true, lastName: true },
|
|
||||||
});
|
|
||||||
if (tenant) {
|
|
||||||
sendBookingRequestToTenant(
|
|
||||||
tenant.email,
|
|
||||||
tenant.firstName,
|
|
||||||
booking.id,
|
|
||||||
carbet.title,
|
|
||||||
booking.startDate,
|
|
||||||
booking.endDate,
|
|
||||||
computedAmount.toFixed(2),
|
|
||||||
"EUR",
|
|
||||||
).catch(() => {});
|
|
||||||
}
|
|
||||||
if (carbet.owner?.email && tenant) {
|
|
||||||
sendBookingRequestToOwner(
|
|
||||||
carbet.owner.email,
|
|
||||||
carbet.owner.firstName,
|
|
||||||
booking.id,
|
|
||||||
carbet.title,
|
|
||||||
`${tenant.firstName} ${tenant.lastName}`.trim(),
|
|
||||||
booking.startDate,
|
|
||||||
booking.endDate,
|
|
||||||
).catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ booking }, { status: 201 });
|
return NextResponse.json({ booking }, { status: 201 });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,11 +26,7 @@ export async function POST(
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return NextResponse.json({ error: "Non authentifié." }, { status: 401 });
|
return NextResponse.json({ error: "Non authentifié." }, { status: 401 });
|
||||||
}
|
}
|
||||||
if (
|
if (session.user.role !== UserRole.OWNER && session.user.role !== UserRole.ADMIN) {
|
||||||
session.user.role !== UserRole.OWNER &&
|
|
||||||
session.user.role !== UserRole.ADMIN &&
|
|
||||||
session.user.role !== UserRole.CE_MANAGER
|
|
||||||
) {
|
|
||||||
return NextResponse.json({ error: "Accès refusé." }, { status: 403 });
|
return NextResponse.json({ error: "Accès refusé." }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -38,15 +34,12 @@ export async function POST(
|
||||||
|
|
||||||
const carbet = await prisma.carbet.findUnique({
|
const carbet = await prisma.carbet.findUnique({
|
||||||
where: { id: carbetId },
|
where: { id: carbetId },
|
||||||
select: {
|
select: { ownerId: true },
|
||||||
ownerId: true,
|
|
||||||
organizations: { select: { organizationId: true } },
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
if (!carbet) {
|
if (!carbet) {
|
||||||
return NextResponse.json({ error: "Carbet introuvable." }, { status: 404 });
|
return NextResponse.json({ error: "Carbet introuvable." }, { status: 404 });
|
||||||
}
|
}
|
||||||
if (!canManageCarbet(session, carbet.ownerId, carbet.organizations.map((o) => o.organizationId))) {
|
if (!canManageCarbet(session, carbet.ownerId)) {
|
||||||
return NextResponse.json({ error: "Accès refusé." }, { status: 403 });
|
return NextResponse.json({ error: "Accès refusé." }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,113 +0,0 @@
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
|
|
||||||
import {
|
|
||||||
BookingStatus,
|
|
||||||
PaymentStatus,
|
|
||||||
RentalBookingStatus,
|
|
||||||
} from "@/generated/prisma/enums";
|
|
||||||
import { recordAudit } from "@/lib/admin/audit";
|
|
||||||
import { isAuthorizedCronRequest } from "@/lib/cron-auth";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
const INVITE_EXPIRY_GRACE_DAYS = 30;
|
|
||||||
const ABANDONED_PENDING_DAYS = 7;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/cron/cleanup
|
|
||||||
*
|
|
||||||
* Purge :
|
|
||||||
* - OrgInviteToken expirés depuis plus de 30j (rétention pour audit court).
|
|
||||||
* - Booking carbet PENDING dont createdAt > 7j et paiement non SUCCEEDED :
|
|
||||||
* status passé à CANCELLED (libère le créneau via cascade des
|
|
||||||
* Availabilities seulement si onDelete CASCADE — ici on flip juste
|
|
||||||
* status pour conserver le log).
|
|
||||||
* - RentalBooking PENDING idem + delete RentalItemAvailability associée
|
|
||||||
* (libère le stock).
|
|
||||||
*
|
|
||||||
* Auth : Bearer CRON_TOKEN.
|
|
||||||
*/
|
|
||||||
export async function GET(req: Request) {
|
|
||||||
if (!isAuthorizedCronRequest(req)) {
|
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
const inviteCutoff = new Date(now.getTime() - INVITE_EXPIRY_GRACE_DAYS * 86_400_000);
|
|
||||||
const abandonedCutoff = new Date(now.getTime() - ABANDONED_PENDING_DAYS * 86_400_000);
|
|
||||||
|
|
||||||
// 1. Invites expirés (expiresAt < cutoff)
|
|
||||||
const { count: invitesDeleted } = await prisma.orgInviteToken.deleteMany({
|
|
||||||
where: { expiresAt: { lt: inviteCutoff } },
|
|
||||||
});
|
|
||||||
|
|
||||||
// 2. Bookings carbet PENDING abandonnés
|
|
||||||
const abandonedBookings = await prisma.booking.findMany({
|
|
||||||
where: {
|
|
||||||
status: BookingStatus.PENDING,
|
|
||||||
paymentStatus: { not: PaymentStatus.SUCCEEDED },
|
|
||||||
createdAt: { lt: abandonedCutoff },
|
|
||||||
},
|
|
||||||
select: { id: true, carbetId: true },
|
|
||||||
});
|
|
||||||
let bookingsCancelled = 0;
|
|
||||||
if (abandonedBookings.length > 0) {
|
|
||||||
const { count } = await prisma.booking.updateMany({
|
|
||||||
where: { id: { in: abandonedBookings.map((b) => b.id) } },
|
|
||||||
data: { status: BookingStatus.CANCELLED, paymentStatus: PaymentStatus.FAILED },
|
|
||||||
});
|
|
||||||
bookingsCancelled = count;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. RentalBookings PENDING abandonnés + delete availability associée
|
|
||||||
const abandonedRentals = await prisma.rentalBooking.findMany({
|
|
||||||
where: {
|
|
||||||
status: RentalBookingStatus.PENDING,
|
|
||||||
paymentStatus: { not: PaymentStatus.SUCCEEDED },
|
|
||||||
createdAt: { lt: abandonedCutoff },
|
|
||||||
},
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
let rentalsCancelled = 0;
|
|
||||||
let availabilityFreed = 0;
|
|
||||||
if (abandonedRentals.length > 0) {
|
|
||||||
const ids = abandonedRentals.map((r) => r.id);
|
|
||||||
const [rentalRes, availRes] = await prisma.$transaction([
|
|
||||||
prisma.rentalBooking.updateMany({
|
|
||||||
where: { id: { in: ids } },
|
|
||||||
data: {
|
|
||||||
status: RentalBookingStatus.CANCELLED,
|
|
||||||
paymentStatus: PaymentStatus.FAILED,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
prisma.rentalItemAvailability.deleteMany({
|
|
||||||
where: { rentalBookingId: { in: ids } },
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
rentalsCancelled = rentalRes.count;
|
|
||||||
availabilityFreed = availRes.count;
|
|
||||||
}
|
|
||||||
|
|
||||||
await recordAudit({
|
|
||||||
scope: "cron",
|
|
||||||
event: "cron.cleanup.run",
|
|
||||||
target: null,
|
|
||||||
actorEmail: "system:cron",
|
|
||||||
details: {
|
|
||||||
invitesDeleted,
|
|
||||||
bookingsCancelled,
|
|
||||||
rentalsCancelled,
|
|
||||||
availabilityFreed,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
ok: true,
|
|
||||||
invitesDeleted,
|
|
||||||
bookingsCancelled,
|
|
||||||
rentalsCancelled,
|
|
||||||
availabilityFreed,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,128 +0,0 @@
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
|
|
||||||
import {
|
|
||||||
BookingStatus,
|
|
||||||
RentalBookingStatus,
|
|
||||||
} from "@/generated/prisma/enums";
|
|
||||||
import { recordAudit } from "@/lib/admin/audit";
|
|
||||||
import { isAuthorizedCronRequest } from "@/lib/cron-auth";
|
|
||||||
import { sendBookingReminder, sendRentalReminder } from "@/lib/email";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/cron/reminders
|
|
||||||
*
|
|
||||||
* Envoie des rappels J-1 (24h avant le début) pour :
|
|
||||||
* - Booking CONFIRMED dont startDate ∈ [now+22h, now+26h]
|
|
||||||
* - RentalBooking CONFIRMED idem
|
|
||||||
*
|
|
||||||
* Idempotent à l'échelle d'une journée : le filtre temporel narrow limite
|
|
||||||
* naturellement le risque de double-envoi (en pratique le cron tourne 1× par
|
|
||||||
* jour à heure fixe). Pour une garantie at-most-once stricte il faudrait
|
|
||||||
* stocker un flag `reminderSentAt` sur Booking/RentalBooking — défensif
|
|
||||||
* mais pas critique pour v1.
|
|
||||||
*
|
|
||||||
* Auth : Bearer CRON_TOKEN.
|
|
||||||
*/
|
|
||||||
export async function GET(req: Request) {
|
|
||||||
if (!isAuthorizedCronRequest(req)) {
|
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
const from = new Date(now.getTime() + 22 * 60 * 60 * 1000);
|
|
||||||
const to = new Date(now.getTime() + 26 * 60 * 60 * 1000);
|
|
||||||
|
|
||||||
const [carbetBookings, rentalBookings] = await Promise.all([
|
|
||||||
prisma.booking.findMany({
|
|
||||||
where: {
|
|
||||||
status: BookingStatus.CONFIRMED,
|
|
||||||
startDate: { gte: from, lt: to },
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
tenant: { select: { email: true, firstName: true } },
|
|
||||||
carbet: { select: { title: true, slug: true } },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
prisma.rentalBooking.findMany({
|
|
||||||
where: {
|
|
||||||
status: RentalBookingStatus.CONFIRMED,
|
|
||||||
startDate: { gte: from, lt: to },
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
tenant: { select: { email: true, firstName: true } },
|
|
||||||
provider: { select: { name: true, contactEmail: true, contactPhone: true } },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
let bookingSent = 0;
|
|
||||||
let bookingErrors = 0;
|
|
||||||
for (const b of carbetBookings) {
|
|
||||||
if (!b.tenant.email) continue;
|
|
||||||
try {
|
|
||||||
await sendBookingReminder(
|
|
||||||
b.tenant.email,
|
|
||||||
b.tenant.firstName,
|
|
||||||
b.id,
|
|
||||||
b.carbet.title,
|
|
||||||
b.startDate,
|
|
||||||
b.carbet.slug,
|
|
||||||
);
|
|
||||||
bookingSent++;
|
|
||||||
} catch (e) {
|
|
||||||
bookingErrors++;
|
|
||||||
console.error(
|
|
||||||
"[cron.reminders] booking email failed:",
|
|
||||||
b.id,
|
|
||||||
e instanceof Error ? e.message : e,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let rentalSent = 0;
|
|
||||||
let rentalErrors = 0;
|
|
||||||
for (const r of rentalBookings) {
|
|
||||||
if (!r.tenant.email) continue;
|
|
||||||
try {
|
|
||||||
await sendRentalReminder(
|
|
||||||
r.tenant.email,
|
|
||||||
r.tenant.firstName,
|
|
||||||
r.id,
|
|
||||||
r.provider.name,
|
|
||||||
r.startDate,
|
|
||||||
{ email: r.provider.contactEmail, phone: r.provider.contactPhone },
|
|
||||||
);
|
|
||||||
rentalSent++;
|
|
||||||
} catch (e) {
|
|
||||||
rentalErrors++;
|
|
||||||
console.error(
|
|
||||||
"[cron.reminders] rental email failed:",
|
|
||||||
r.id,
|
|
||||||
e instanceof Error ? e.message : e,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await recordAudit({
|
|
||||||
scope: "cron",
|
|
||||||
event: "cron.reminders.run",
|
|
||||||
target: null,
|
|
||||||
actorEmail: "system:cron",
|
|
||||||
details: {
|
|
||||||
window: { from: from.toISOString(), to: to.toISOString() },
|
|
||||||
booking: { candidates: carbetBookings.length, sent: bookingSent, errors: bookingErrors },
|
|
||||||
rental: { candidates: rentalBookings.length, sent: rentalSent, errors: rentalErrors },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
ok: true,
|
|
||||||
window: { from: from.toISOString(), to: to.toISOString() },
|
|
||||||
booking: { candidates: carbetBookings.length, sent: bookingSent, errors: bookingErrors },
|
|
||||||
rental: { candidates: rentalBookings.length, sent: rentalSent, errors: rentalErrors },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
|
|
||||||
import { SCHEDULED_TASKS, type ScheduledTaskName } from "@/lib/scheduled";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
function authorized(req: Request): boolean {
|
|
||||||
const secret = (process.env.CRON_TOKEN ?? "").trim();
|
|
||||||
if (!secret) return false;
|
|
||||||
const header = req.headers.get("authorization") ?? "";
|
|
||||||
const token = header.startsWith("Bearer ") ? header.slice(7) : "";
|
|
||||||
return token === secret;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(req: Request, ctx: { params: Promise<{ task: string }> }) {
|
|
||||||
if (!authorized(req)) {
|
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
||||||
}
|
|
||||||
const { task } = await ctx.params;
|
|
||||||
const fn = SCHEDULED_TASKS[task as ScheduledTaskName];
|
|
||||||
if (!fn) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: `Unknown task. Available: ${Object.keys(SCHEDULED_TASKS).join(", ")}` },
|
|
||||||
{ status: 404 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const result = await fn();
|
|
||||||
return NextResponse.json({ ok: true, task, result });
|
|
||||||
} catch (e) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: e instanceof Error ? e.message : String(e) },
|
|
||||||
{ status: 500 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
import { auth } from "@/auth";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
|
|
||||||
const schema = z.object({
|
|
||||||
carbetId: z.string().min(1),
|
|
||||||
});
|
|
||||||
|
|
||||||
async function requireSelf() {
|
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user?.id) throw new Error("Unauth");
|
|
||||||
return session.user.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
try {
|
|
||||||
const userId = await requireSelf();
|
|
||||||
const rows = await prisma.favorite.findMany({
|
|
||||||
where: { userId },
|
|
||||||
orderBy: { createdAt: "desc" },
|
|
||||||
select: { carbetId: true },
|
|
||||||
});
|
|
||||||
return NextResponse.json({ ids: rows.map((r) => r.carbetId) });
|
|
||||||
} catch {
|
|
||||||
return NextResponse.json({ ids: [] });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
|
||||||
try {
|
|
||||||
const userId = await requireSelf();
|
|
||||||
const parsed = schema.safeParse(await req.json().catch(() => ({})));
|
|
||||||
if (!parsed.success) return NextResponse.json({ error: "Payload invalide" }, { status: 400 });
|
|
||||||
await prisma.favorite.upsert({
|
|
||||||
where: { userId_carbetId: { userId, carbetId: parsed.data.carbetId } },
|
|
||||||
create: { userId, carbetId: parsed.data.carbetId },
|
|
||||||
update: {},
|
|
||||||
});
|
|
||||||
return NextResponse.json({ ok: true });
|
|
||||||
} catch {
|
|
||||||
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function DELETE(req: Request) {
|
|
||||||
try {
|
|
||||||
const userId = await requireSelf();
|
|
||||||
const parsed = schema.safeParse(await req.json().catch(() => ({})));
|
|
||||||
if (!parsed.success) return NextResponse.json({ error: "Payload invalide" }, { status: 400 });
|
|
||||||
await prisma.favorite
|
|
||||||
.delete({ where: { userId_carbetId: { userId, carbetId: parsed.data.carbetId } } })
|
|
||||||
.catch(() => null);
|
|
||||||
return NextResponse.json({ ok: true });
|
|
||||||
} catch {
|
|
||||||
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,101 +1,7 @@
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { S3Client, HeadBucketCommand } from "@aws-sdk/client-s3";
|
|
||||||
|
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
type Probe = {
|
|
||||||
name: string;
|
|
||||||
ok: boolean;
|
|
||||||
latencyMs: number;
|
|
||||||
details?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
async function probeDb(): Promise<Probe> {
|
|
||||||
const t0 = Date.now();
|
|
||||||
try {
|
|
||||||
await prisma.$queryRaw`SELECT 1 AS ok`;
|
|
||||||
return { name: "database", ok: true, latencyMs: Date.now() - t0 };
|
|
||||||
} catch (e) {
|
|
||||||
return {
|
|
||||||
name: "database",
|
|
||||||
ok: false,
|
|
||||||
latencyMs: Date.now() - t0,
|
|
||||||
details: e instanceof Error ? e.message : String(e),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function probeS3(): Promise<Probe> {
|
|
||||||
const t0 = Date.now();
|
|
||||||
const bucket = process.env.S3_BUCKET;
|
|
||||||
const endpoint = process.env.S3_ENDPOINT;
|
|
||||||
if (!bucket || !endpoint) {
|
|
||||||
return { name: "s3", ok: false, latencyMs: 0, details: "S3_BUCKET ou S3_ENDPOINT manquant" };
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const client = new S3Client({
|
|
||||||
endpoint,
|
|
||||||
region: process.env.S3_REGION ?? "us-east-1",
|
|
||||||
forcePathStyle: (process.env.S3_FORCE_PATH_STYLE ?? "false") === "true",
|
|
||||||
credentials: {
|
|
||||||
accessKeyId: process.env.MINIO_ROOT_USER ?? process.env.S3_ACCESS_KEY ?? "",
|
|
||||||
secretAccessKey: process.env.MINIO_ROOT_PASSWORD ?? process.env.S3_SECRET_KEY ?? "",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await client.send(new HeadBucketCommand({ Bucket: bucket }));
|
|
||||||
return { name: "s3", ok: true, latencyMs: Date.now() - t0 };
|
|
||||||
} catch (e) {
|
|
||||||
return {
|
|
||||||
name: "s3",
|
|
||||||
ok: false,
|
|
||||||
latencyMs: Date.now() - t0,
|
|
||||||
details: e instanceof Error ? e.message : String(e),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function probeResend(): Probe {
|
|
||||||
return {
|
|
||||||
name: "resend",
|
|
||||||
ok: Boolean(process.env.RESEND_API_KEY?.trim()),
|
|
||||||
latencyMs: 0,
|
|
||||||
details: process.env.RESEND_API_KEY ? undefined : "RESEND_API_KEY non configuré (dry-run)",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function probeStripe(): Probe {
|
|
||||||
const key = (process.env.STRIPE_SECRET_KEY ?? "").trim();
|
|
||||||
const configured = key.length > 0 && !key.includes("REPLACE_ME");
|
|
||||||
return {
|
|
||||||
name: "stripe",
|
|
||||||
ok: configured,
|
|
||||||
latencyMs: 0,
|
|
||||||
details: configured ? undefined : "STRIPE_SECRET_KEY non configuré",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const t0 = Date.now();
|
return NextResponse.json({ status: "ok" });
|
||||||
const [db, s3] = await Promise.all([probeDb(), probeS3()]);
|
|
||||||
const resend = probeResend();
|
|
||||||
const stripe = probeStripe();
|
|
||||||
const probes = [db, s3, resend, stripe];
|
|
||||||
|
|
||||||
// DB est critique (503 si down). Le reste = non bloquant.
|
|
||||||
const critical = db.ok;
|
|
||||||
const status = critical ? 200 : 503;
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
status: critical ? "ok" : "degraded",
|
|
||||||
version: process.env.DEPLOYMENT_VERSION ?? "unknown",
|
|
||||||
uptimeSeconds: Math.round(process.uptime()),
|
|
||||||
latencyMs: Date.now() - t0,
|
|
||||||
probes,
|
|
||||||
},
|
|
||||||
{ status },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
|
|
||||||
import { auth } from "@/auth";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { recordAudit } from "@/lib/admin/audit";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
/** RGPD article 20 — droit à la portabilité. Renvoie un JSON avec toutes les données utilisateur. */
|
|
||||||
export async function GET() {
|
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user?.id) {
|
|
||||||
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
|
|
||||||
}
|
|
||||||
const userId = session.user.id;
|
|
||||||
|
|
||||||
const [user, bookings, reviews, carbets, subscriptions] = await Promise.all([
|
|
||||||
prisma.user.findUnique({
|
|
||||||
where: { id: userId },
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
email: true,
|
|
||||||
firstName: true,
|
|
||||||
lastName: true,
|
|
||||||
phone: true,
|
|
||||||
role: true,
|
|
||||||
avatarUrl: true,
|
|
||||||
isActive: true,
|
|
||||||
createdAt: true,
|
|
||||||
updatedAt: true,
|
|
||||||
organizationId: true,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
prisma.booking.findMany({
|
|
||||||
where: { tenantId: userId },
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
carbetId: true,
|
|
||||||
startDate: true,
|
|
||||||
endDate: true,
|
|
||||||
guestCount: true,
|
|
||||||
status: true,
|
|
||||||
paymentStatus: true,
|
|
||||||
amount: true,
|
|
||||||
currency: true,
|
|
||||||
createdAt: true,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
prisma.review.findMany({
|
|
||||||
where: { authorId: userId },
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
bookingId: true,
|
|
||||||
carbetId: true,
|
|
||||||
rating: true,
|
|
||||||
comment: true,
|
|
||||||
createdAt: true,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
prisma.carbet.findMany({
|
|
||||||
where: { ownerId: userId },
|
|
||||||
select: { id: true, slug: true, title: true, status: true, createdAt: true },
|
|
||||||
}),
|
|
||||||
prisma.subscription.findMany({
|
|
||||||
where: { ownerId: userId },
|
|
||||||
select: { id: true, carbetId: true, status: true, provider: true, startedAt: true },
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
await recordAudit({
|
|
||||||
scope: "public.profile",
|
|
||||||
event: "data.export",
|
|
||||||
target: userId,
|
|
||||||
actorEmail: session.user.email ?? null,
|
|
||||||
details: {},
|
|
||||||
});
|
|
||||||
|
|
||||||
const filename = `karbe-mes-donnees-${new Date().toISOString().slice(0, 10)}.json`;
|
|
||||||
return new NextResponse(
|
|
||||||
JSON.stringify(
|
|
||||||
{
|
|
||||||
exportedAt: new Date().toISOString(),
|
|
||||||
rgpdNotice:
|
|
||||||
"Conformément à l'article 20 du RGPD. Pour exercer vos autres droits, contactez contact@karbe.cosmolan.fr.",
|
|
||||||
user,
|
|
||||||
bookings,
|
|
||||||
reviews,
|
|
||||||
carbets,
|
|
||||||
subscriptions,
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
{
|
|
||||||
status: 200,
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json; charset=utf-8",
|
|
||||||
"Content-Disposition": `attachment; filename="${filename}"`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
|
|
||||||
import { auth } from "@/auth";
|
|
||||||
import { UserRole } from "@/generated/prisma/enums";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { recordAudit } from "@/lib/admin/audit";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
|
|
||||||
async function requireOwnership(mediaId: string) {
|
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user?.id) throw new Error("Non authentifié");
|
|
||||||
const m = await prisma.media.findUnique({
|
|
||||||
where: { id: mediaId },
|
|
||||||
select: { id: true, carbetId: true, carbet: { select: { ownerId: true } } },
|
|
||||||
});
|
|
||||||
if (!m) throw new Error("Média introuvable");
|
|
||||||
const isAdmin = session.user.role === UserRole.ADMIN;
|
|
||||||
if (!isAdmin && m.carbet.ownerId !== session.user.id) throw new Error("Accès refusé");
|
|
||||||
return { session, media: m };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function DELETE(_req: Request, ctx: { params: Promise<{ id: string }> }) {
|
|
||||||
const { id } = await ctx.params;
|
|
||||||
try {
|
|
||||||
const { session, media } = await requireOwnership(id);
|
|
||||||
await prisma.media.delete({ where: { id } });
|
|
||||||
await recordAudit({
|
|
||||||
scope: "uploads",
|
|
||||||
event: "media.delete",
|
|
||||||
target: id,
|
|
||||||
actorEmail: session.user.email ?? null,
|
|
||||||
details: { carbetId: media.carbetId },
|
|
||||||
});
|
|
||||||
return NextResponse.json({ ok: true });
|
|
||||||
} catch (e) {
|
|
||||||
const msg = e instanceof Error ? e.message : String(e);
|
|
||||||
const status = msg === "Non authentifié" ? 401 : msg === "Accès refusé" ? 403 : 404;
|
|
||||||
return NextResponse.json({ error: msg }, { status });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
import { auth } from "@/auth";
|
|
||||||
import { UserRole } from "@/generated/prisma/enums";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { recordAudit } from "@/lib/admin/audit";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
|
|
||||||
const schema = z.object({
|
|
||||||
carbetId: z.string().min(1),
|
|
||||||
orderedIds: z.array(z.string()).min(1).max(50),
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user?.id) {
|
|
||||||
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
|
|
||||||
}
|
|
||||||
const parsed = schema.safeParse(await req.json().catch(() => ({})));
|
|
||||||
if (!parsed.success) {
|
|
||||||
return NextResponse.json({ error: "Payload invalide" }, { status: 400 });
|
|
||||||
}
|
|
||||||
const { carbetId, orderedIds } = parsed.data;
|
|
||||||
const carbet = await prisma.carbet.findUnique({
|
|
||||||
where: { id: carbetId },
|
|
||||||
select: { ownerId: true },
|
|
||||||
});
|
|
||||||
if (!carbet) return NextResponse.json({ error: "Carbet introuvable" }, { status: 404 });
|
|
||||||
const isAdmin = session.user.role === UserRole.ADMIN;
|
|
||||||
if (!isAdmin && carbet.ownerId !== session.user.id) {
|
|
||||||
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
|
|
||||||
}
|
|
||||||
const existing = await prisma.media.findMany({
|
|
||||||
where: { carbetId, id: { in: orderedIds } },
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
if (existing.length !== orderedIds.length) {
|
|
||||||
return NextResponse.json({ error: "Certains médias n'appartiennent pas au carbet." }, { status: 400 });
|
|
||||||
}
|
|
||||||
await prisma.$transaction(
|
|
||||||
orderedIds.map((id, idx) =>
|
|
||||||
prisma.media.update({ where: { id }, data: { sortOrder: idx } }),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await recordAudit({
|
|
||||||
scope: "uploads",
|
|
||||||
event: "media.reorder",
|
|
||||||
target: carbetId,
|
|
||||||
actorEmail: session.user.email ?? null,
|
|
||||||
details: { count: orderedIds.length },
|
|
||||||
});
|
|
||||||
return NextResponse.json({ ok: true });
|
|
||||||
}
|
|
||||||
|
|
@ -1,78 +0,0 @@
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
|
|
||||||
import { BookingStatus, CarbetStatus, UserRole } from "@/generated/prisma/enums";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Metrics publiques, agrégées (jamais de PII).
|
|
||||||
* Format JSON simple — consommable par un script cron ou un dashboard léger.
|
|
||||||
*/
|
|
||||||
export async function GET() {
|
|
||||||
const now = new Date();
|
|
||||||
const last24h = new Date(now.getTime() - 86_400_000);
|
|
||||||
const last7d = new Date(now.getTime() - 7 * 86_400_000);
|
|
||||||
const last30d = new Date(now.getTime() - 30 * 86_400_000);
|
|
||||||
|
|
||||||
const [
|
|
||||||
carbetsPublished,
|
|
||||||
carbetsTotal,
|
|
||||||
bookings24h,
|
|
||||||
bookings7d,
|
|
||||||
bookings30d,
|
|
||||||
bookingsByStatus,
|
|
||||||
usersTotal,
|
|
||||||
usersByRole,
|
|
||||||
mediaTotal,
|
|
||||||
auditLast24h,
|
|
||||||
] = await Promise.all([
|
|
||||||
prisma.carbet.count({ where: { status: CarbetStatus.PUBLISHED } }),
|
|
||||||
prisma.carbet.count(),
|
|
||||||
prisma.booking.count({ where: { createdAt: { gte: last24h } } }),
|
|
||||||
prisma.booking.count({ where: { createdAt: { gte: last7d } } }),
|
|
||||||
prisma.booking.count({ where: { createdAt: { gte: last30d } } }),
|
|
||||||
prisma.booking.groupBy({
|
|
||||||
by: ["status"],
|
|
||||||
_count: { _all: true },
|
|
||||||
}),
|
|
||||||
prisma.user.count(),
|
|
||||||
prisma.user.groupBy({
|
|
||||||
by: ["role"],
|
|
||||||
_count: { _all: true },
|
|
||||||
}),
|
|
||||||
prisma.media.count(),
|
|
||||||
prisma.auditLog.count({ where: { createdAt: { gte: last24h } } }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
generatedAt: now.toISOString(),
|
|
||||||
carbets: {
|
|
||||||
total: carbetsTotal,
|
|
||||||
published: carbetsPublished,
|
|
||||||
},
|
|
||||||
bookings: {
|
|
||||||
last24h: bookings24h,
|
|
||||||
last7d: bookings7d,
|
|
||||||
last30d: bookings30d,
|
|
||||||
byStatus: Object.fromEntries(
|
|
||||||
Object.values(BookingStatus).map((s) => [
|
|
||||||
s,
|
|
||||||
bookingsByStatus.find((b) => b.status === s)?._count._all ?? 0,
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
users: {
|
|
||||||
total: usersTotal,
|
|
||||||
byRole: Object.fromEntries(
|
|
||||||
Object.values(UserRole).map((r) => [
|
|
||||||
r,
|
|
||||||
usersByRole.find((u) => u.role === r)?._count._all ?? 0,
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
media: { total: mediaTotal },
|
|
||||||
audit: { last24h: auditLast24h },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
import { createPasswordResetToken } from "@/lib/password-reset";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { sendPasswordReset } from "@/lib/email";
|
|
||||||
import { recordAudit } from "@/lib/admin/audit";
|
|
||||||
import { rateLimitRequest } from "@/lib/rate-limit";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
|
|
||||||
const schema = z.object({
|
|
||||||
email: z.string().trim().toLowerCase().email(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://karbe.cosmolan.fr";
|
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
|
||||||
const rl = rateLimitRequest(req, "password-reset", 60 * 60 * 1000, 3);
|
|
||||||
if (!rl.ok) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: `Trop de tentatives. Réessayez dans ${rl.retryAfter}s.` },
|
|
||||||
{ status: 429, headers: { "Retry-After": String(rl.retryAfter) } },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let body: unknown;
|
|
||||||
try {
|
|
||||||
body = await req.json();
|
|
||||||
} catch {
|
|
||||||
return NextResponse.json({ error: "Corps JSON invalide." }, { status: 400 });
|
|
||||||
}
|
|
||||||
const parsed = schema.safeParse(body);
|
|
||||||
if (!parsed.success) {
|
|
||||||
// Réponse générique pour ne pas leak la validité du format à un attaquant.
|
|
||||||
return NextResponse.json({ ok: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({
|
|
||||||
where: { email: parsed.data.email },
|
|
||||||
select: { id: true, email: true, firstName: true, isActive: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (user && user.isActive) {
|
|
||||||
const token = await createPasswordResetToken(user.id);
|
|
||||||
const resetUrl = `${SITE_URL}/mot-de-passe-oublie/${token}`;
|
|
||||||
sendPasswordReset(user.email, resetUrl).catch(() => {});
|
|
||||||
await recordAudit({
|
|
||||||
scope: "public.password",
|
|
||||||
event: "reset.request",
|
|
||||||
target: user.id,
|
|
||||||
actorEmail: user.email,
|
|
||||||
details: {},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Réponse identique que l'email existe ou non (énumération-safe).
|
|
||||||
return NextResponse.json({ ok: true });
|
|
||||||
}
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
import { consumePasswordResetToken } from "@/lib/password-reset";
|
|
||||||
import { recordAudit } from "@/lib/admin/audit";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
|
|
||||||
const schema = z.object({
|
|
||||||
token: z.string().min(20).max(200),
|
|
||||||
password: z.string().min(8).max(200),
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
|
||||||
let body: unknown;
|
|
||||||
try {
|
|
||||||
body = await req.json();
|
|
||||||
} catch {
|
|
||||||
return NextResponse.json({ error: "Corps JSON invalide." }, { status: 400 });
|
|
||||||
}
|
|
||||||
const parsed = schema.safeParse(body);
|
|
||||||
if (!parsed.success) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: parsed.error.issues.map((i) => i.message).join(" · ") },
|
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const result = await consumePasswordResetToken(parsed.data.token, parsed.data.password);
|
|
||||||
if (!result.ok) {
|
|
||||||
return NextResponse.json({ error: result.reason }, { status: 400 });
|
|
||||||
}
|
|
||||||
await recordAudit({
|
|
||||||
scope: "public.password",
|
|
||||||
event: "reset.success",
|
|
||||||
target: result.userId,
|
|
||||||
actorEmail: null,
|
|
||||||
details: {},
|
|
||||||
});
|
|
||||||
return NextResponse.json({ ok: true });
|
|
||||||
}
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
|
|
||||||
import { auth } from "@/auth";
|
|
||||||
import { recordAudit } from "@/lib/admin/audit";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { canManageRentalProvider } from "@/lib/rental-access";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
|
|
||||||
export async function DELETE(_req: Request, ctx: { params: Promise<{ id: string }> }) {
|
|
||||||
const { id } = await ctx.params;
|
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user?.id) {
|
|
||||||
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
|
|
||||||
}
|
|
||||||
const media = await prisma.rentalItemMedia.findUnique({
|
|
||||||
where: { id },
|
|
||||||
select: { id: true, itemId: true, item: { select: { providerId: true } } },
|
|
||||||
});
|
|
||||||
if (!media) return NextResponse.json({ error: "Média introuvable" }, { status: 404 });
|
|
||||||
|
|
||||||
const allowed = await canManageRentalProvider(
|
|
||||||
session.user.id,
|
|
||||||
session.user.role,
|
|
||||||
media.item.providerId,
|
|
||||||
session.user.organizationId,
|
|
||||||
);
|
|
||||||
if (!allowed) return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
|
|
||||||
|
|
||||||
await prisma.rentalItemMedia.delete({ where: { id } });
|
|
||||||
await recordAudit({
|
|
||||||
scope: "uploads",
|
|
||||||
event: "rental.media.delete",
|
|
||||||
target: id,
|
|
||||||
actorEmail: session.user.email ?? null,
|
|
||||||
details: { itemId: media.itemId },
|
|
||||||
});
|
|
||||||
return NextResponse.json({ ok: true });
|
|
||||||
}
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
import { auth } from "@/auth";
|
|
||||||
import { recordAudit } from "@/lib/admin/audit";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { canManageRentalProvider } from "@/lib/rental-access";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
|
|
||||||
const schema = z.object({
|
|
||||||
itemId: z.string().min(1),
|
|
||||||
orderedIds: z.array(z.string()).min(1).max(50),
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user?.id) {
|
|
||||||
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
|
|
||||||
}
|
|
||||||
const parsed = schema.safeParse(await req.json().catch(() => ({})));
|
|
||||||
if (!parsed.success) {
|
|
||||||
return NextResponse.json({ error: "Payload invalide" }, { status: 400 });
|
|
||||||
}
|
|
||||||
const { itemId, orderedIds } = parsed.data;
|
|
||||||
|
|
||||||
const item = await prisma.rentalItem.findUnique({
|
|
||||||
where: { id: itemId },
|
|
||||||
select: { providerId: true },
|
|
||||||
});
|
|
||||||
if (!item) return NextResponse.json({ error: "Item introuvable" }, { status: 404 });
|
|
||||||
|
|
||||||
const allowed = await canManageRentalProvider(
|
|
||||||
session.user.id,
|
|
||||||
session.user.role,
|
|
||||||
item.providerId,
|
|
||||||
session.user.organizationId,
|
|
||||||
);
|
|
||||||
if (!allowed) return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
|
|
||||||
|
|
||||||
const existing = await prisma.rentalItemMedia.findMany({
|
|
||||||
where: { itemId, id: { in: orderedIds } },
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
if (existing.length !== orderedIds.length) {
|
|
||||||
return NextResponse.json({ error: "Certains médias n'appartiennent pas à l'item." }, { status: 400 });
|
|
||||||
}
|
|
||||||
await prisma.$transaction(
|
|
||||||
orderedIds.map((id, idx) =>
|
|
||||||
prisma.rentalItemMedia.update({ where: { id }, data: { sortOrder: idx } }),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Cover = sortOrder 0 → hydrate imageUrl pour rétro-compat listings
|
|
||||||
const firstId = orderedIds[0];
|
|
||||||
const firstMedia = await prisma.rentalItemMedia.findUnique({
|
|
||||||
where: { id: firstId },
|
|
||||||
select: { s3Url: true, type: true },
|
|
||||||
});
|
|
||||||
if (firstMedia && firstMedia.type === "PHOTO") {
|
|
||||||
await prisma.rentalItem.update({
|
|
||||||
where: { id: itemId },
|
|
||||||
data: { imageUrl: firstMedia.s3Url },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await recordAudit({
|
|
||||||
scope: "uploads",
|
|
||||||
event: "rental.media.reorder",
|
|
||||||
target: itemId,
|
|
||||||
actorEmail: session.user.email ?? null,
|
|
||||||
details: { count: orderedIds.length },
|
|
||||||
});
|
|
||||||
return NextResponse.json({ ok: true });
|
|
||||||
}
|
|
||||||
|
|
@ -1,193 +0,0 @@
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
|
|
||||||
import { auth } from "@/auth";
|
|
||||||
import {
|
|
||||||
PaymentStatus,
|
|
||||||
RentalBookingStatus,
|
|
||||||
UserRole,
|
|
||||||
} from "@/generated/prisma/enums";
|
|
||||||
import { recordAudit } from "@/lib/admin/audit";
|
|
||||||
import { canManageRentalProvider } from "@/lib/rental-access";
|
|
||||||
import { sendRentalCancelled } from "@/lib/email";
|
|
||||||
import { isStripeConfigured, getStripeClient } from "@/lib/stripe";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { computeRentalRefund } from "@/lib/rental-refund";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
|
|
||||||
const CANCELLABLE_STATUSES: RentalBookingStatus[] = [
|
|
||||||
RentalBookingStatus.PENDING,
|
|
||||||
RentalBookingStatus.CONFIRMED,
|
|
||||||
];
|
|
||||||
|
|
||||||
type Body = { reason?: string };
|
|
||||||
|
|
||||||
export async function POST(
|
|
||||||
req: Request,
|
|
||||||
{ params }: { params: Promise<{ id: string }> },
|
|
||||||
) {
|
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user?.id) {
|
|
||||||
return NextResponse.json({ error: "Non authentifié." }, { status: 401 });
|
|
||||||
}
|
|
||||||
const { id } = await params;
|
|
||||||
const body: Body = await req.json().catch(() => ({}));
|
|
||||||
const reason = body.reason?.toString().trim().slice(0, 500) ?? null;
|
|
||||||
|
|
||||||
const rb = await prisma.rentalBooking.findUnique({
|
|
||||||
where: { id },
|
|
||||||
include: {
|
|
||||||
provider: { select: { id: true, name: true, contactEmail: true, organizationId: true } },
|
|
||||||
tenant: { select: { id: true, email: true, firstName: true } },
|
|
||||||
lines: { select: { qty: true, item: { select: { name: true } } } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!rb) {
|
|
||||||
return NextResponse.json({ error: "Réservation introuvable." }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Détecte qui annule pour l'auth + l'email :
|
|
||||||
// - tenant de la booking
|
|
||||||
// - provider's manager (RENTAL_PROVIDER nominal ou CE_MANAGER de l'org du provider)
|
|
||||||
// - admin
|
|
||||||
const role = session.user.role;
|
|
||||||
const isTenant = rb.tenantId === session.user.id;
|
|
||||||
const isAdmin = role === UserRole.ADMIN;
|
|
||||||
const canManage = await canManageRentalProvider(
|
|
||||||
session.user.id,
|
|
||||||
role,
|
|
||||||
rb.providerId,
|
|
||||||
session.user.organizationId,
|
|
||||||
);
|
|
||||||
const cancelledBy: "tenant" | "provider" | "admin" = isAdmin
|
|
||||||
? "admin"
|
|
||||||
: canManage
|
|
||||||
? "provider"
|
|
||||||
: "tenant";
|
|
||||||
|
|
||||||
if (!isAdmin && !canManage && !isTenant) {
|
|
||||||
return NextResponse.json({ error: "Accès refusé." }, { status: 403 });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!CANCELLABLE_STATUSES.includes(rb.status)) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: `Impossible d'annuler une réservation en statut ${rb.status}.` },
|
|
||||||
{ status: 409 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calcule le remboursement selon la politique
|
|
||||||
const refund = computeRentalRefund({
|
|
||||||
startDate: rb.startDate,
|
|
||||||
itemsTotal: rb.itemsTotal,
|
|
||||||
depositTotal: rb.depositTotal,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Stripe refund best-effort si paiement déjà SUCCEEDED + session Stripe existante
|
|
||||||
let stripeRefundId: string | null = null;
|
|
||||||
let stripeRefundError: string | null = null;
|
|
||||||
if (
|
|
||||||
rb.paymentStatus === PaymentStatus.SUCCEEDED &&
|
|
||||||
rb.stripeSessionId &&
|
|
||||||
isStripeConfigured() &&
|
|
||||||
refund.totalRefund.gt(0)
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const stripe = getStripeClient();
|
|
||||||
const sess = await stripe.checkout.sessions.retrieve(rb.stripeSessionId, {
|
|
||||||
expand: ["payment_intent"],
|
|
||||||
});
|
|
||||||
const piId =
|
|
||||||
typeof sess.payment_intent === "string"
|
|
||||||
? sess.payment_intent
|
|
||||||
: sess.payment_intent?.id;
|
|
||||||
if (piId) {
|
|
||||||
const stripeRefund = await stripe.refunds.create({
|
|
||||||
payment_intent: piId,
|
|
||||||
amount: Math.round(Number(refund.totalRefund) * 100),
|
|
||||||
reason: "requested_by_customer",
|
|
||||||
});
|
|
||||||
stripeRefundId = stripeRefund.id;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
stripeRefundError = e instanceof Error ? e.message : String(e);
|
|
||||||
console.error("[rental.cancel] Stripe refund failed:", stripeRefundError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Transaction : update booking + delete availability blocks
|
|
||||||
await prisma.$transaction([
|
|
||||||
prisma.rentalBooking.update({
|
|
||||||
where: { id },
|
|
||||||
data: {
|
|
||||||
status: RentalBookingStatus.CANCELLED,
|
|
||||||
paymentStatus:
|
|
||||||
rb.paymentStatus === PaymentStatus.SUCCEEDED
|
|
||||||
? PaymentStatus.REFUNDED
|
|
||||||
: PaymentStatus.FAILED,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
prisma.rentalItemAvailability.deleteMany({
|
|
||||||
where: { rentalBookingId: id },
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
await recordAudit({
|
|
||||||
scope: "rental",
|
|
||||||
event: "rental.cancel",
|
|
||||||
target: id,
|
|
||||||
actorEmail: session.user.email ?? null,
|
|
||||||
details: {
|
|
||||||
cancelledBy,
|
|
||||||
reason,
|
|
||||||
policy: refund.policy,
|
|
||||||
itemsRefund: refund.itemsRefund.toString(),
|
|
||||||
depositRefund: refund.depositRefund.toString(),
|
|
||||||
totalRefund: refund.totalRefund.toString(),
|
|
||||||
stripeRefundId,
|
|
||||||
stripeRefundError,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Email best-effort : tenant + provider
|
|
||||||
try {
|
|
||||||
await sendRentalCancelled(
|
|
||||||
rb.tenant.email,
|
|
||||||
rb.tenant.firstName,
|
|
||||||
rb.id,
|
|
||||||
rb.provider.name,
|
|
||||||
refund.totalRefund.toString(),
|
|
||||||
rb.currency,
|
|
||||||
refund.policyLabel,
|
|
||||||
cancelledBy,
|
|
||||||
);
|
|
||||||
if (rb.provider.contactEmail && cancelledBy !== "provider") {
|
|
||||||
await sendRentalCancelled(
|
|
||||||
rb.provider.contactEmail,
|
|
||||||
rb.provider.name,
|
|
||||||
rb.id,
|
|
||||||
rb.provider.name,
|
|
||||||
refund.totalRefund.toString(),
|
|
||||||
rb.currency,
|
|
||||||
refund.policyLabel,
|
|
||||||
cancelledBy,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error("[rental.cancel] email send failed:", e instanceof Error ? e.message : e);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
ok: true,
|
|
||||||
rentalBookingId: id,
|
|
||||||
refund: {
|
|
||||||
itemsRefund: refund.itemsRefund.toNumber(),
|
|
||||||
depositRefund: refund.depositRefund.toNumber(),
|
|
||||||
totalRefund: refund.totalRefund.toNumber(),
|
|
||||||
policy: refund.policy,
|
|
||||||
policyLabel: refund.policyLabel,
|
|
||||||
},
|
|
||||||
stripeRefundId,
|
|
||||||
stripeRefundError,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,361 +0,0 @@
|
||||||
import { cookies } from "next/headers";
|
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
|
|
||||||
import { auth } from "@/auth";
|
|
||||||
import { PaymentStatus, RentalBookingStatus } from "@/generated/prisma/enums";
|
|
||||||
import { Prisma } from "@/generated/prisma/client";
|
|
||||||
import { recordAudit } from "@/lib/admin/audit";
|
|
||||||
import {
|
|
||||||
sendRentalRequestedProvider,
|
|
||||||
sendRentalRequestedTenant,
|
|
||||||
} from "@/lib/email";
|
|
||||||
import { isPluginEnabled } from "@/lib/plugins/server";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { CART_COOKIE, EMPTY_CART, diffDays, parseCart } from "@/lib/rental-cart";
|
|
||||||
import {
|
|
||||||
getStripeClient,
|
|
||||||
isStripeConfigured,
|
|
||||||
toStripeAmountCents,
|
|
||||||
} from "@/lib/stripe";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
|
|
||||||
type LineInput = {
|
|
||||||
itemId: string;
|
|
||||||
qty: number;
|
|
||||||
startDate: Date;
|
|
||||||
endDate: Date;
|
|
||||||
nights: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
function parseDateOnly(s: string): Date {
|
|
||||||
return new Date(s + "T00:00:00Z");
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST() {
|
|
||||||
if (!(await isPluginEnabled("gear-rental"))) {
|
|
||||||
return NextResponse.json({ error: "Service de location indisponible." }, { status: 404 });
|
|
||||||
}
|
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user?.id || !session.user.email) {
|
|
||||||
return NextResponse.json({ error: "Connectez-vous pour finaliser." }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const jar = await cookies();
|
|
||||||
const cart = parseCart(jar.get(CART_COOKIE)?.value);
|
|
||||||
if (cart.items.length === 0) {
|
|
||||||
return NextResponse.json({ error: "Panier vide." }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Charge tous les items du panier
|
|
||||||
const itemIds = Array.from(new Set(cart.items.map((e) => e.itemId)));
|
|
||||||
const items = await prisma.rentalItem.findMany({
|
|
||||||
where: { id: { in: itemIds }, active: true },
|
|
||||||
include: {
|
|
||||||
provider: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
active: true,
|
|
||||||
approved: true,
|
|
||||||
commissionPct: true,
|
|
||||||
isSystemD: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const itemById = new Map(items.map((i) => [i.id, i]));
|
|
||||||
|
|
||||||
// Validations préliminaires : items valides + provider actif/approved
|
|
||||||
for (const entry of cart.items) {
|
|
||||||
const it = itemById.get(entry.itemId);
|
|
||||||
if (!it) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: `Item ${entry.itemId} introuvable ou désactivé.` },
|
|
||||||
{ status: 409 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!it.provider.active || !it.provider.approved) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: `Prestataire ${it.provider.name} indisponible.` },
|
|
||||||
{ status: 409 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (entry.qty < 1 || entry.qty > it.totalQty) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: `Quantité invalide pour « ${it.name} ».` },
|
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const start = parseDateOnly(entry.startDate);
|
|
||||||
const end = parseDateOnly(entry.endDate);
|
|
||||||
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime()) || end <= start) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: `Dates invalides pour « ${it.name} ».` },
|
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Groupe par provider
|
|
||||||
type Group = {
|
|
||||||
providerId: string;
|
|
||||||
providerName: string;
|
|
||||||
commissionPct: number;
|
|
||||||
lines: LineInput[];
|
|
||||||
itemsTotal: Prisma.Decimal;
|
|
||||||
depositTotal: Prisma.Decimal;
|
|
||||||
startDate: Date;
|
|
||||||
endDate: Date;
|
|
||||||
};
|
|
||||||
|
|
||||||
const groups = new Map<string, Group>();
|
|
||||||
for (const entry of cart.items) {
|
|
||||||
const it = itemById.get(entry.itemId)!;
|
|
||||||
const start = parseDateOnly(entry.startDate);
|
|
||||||
const end = parseDateOnly(entry.endDate);
|
|
||||||
const nights = Math.max(1, diffDays(entry.startDate, entry.endDate));
|
|
||||||
const lineSub = new Prisma.Decimal(it.pricePerDay).mul(entry.qty).mul(nights);
|
|
||||||
const lineDeposit = new Prisma.Decimal(it.deposit).mul(entry.qty);
|
|
||||||
|
|
||||||
let g = groups.get(it.provider.id);
|
|
||||||
if (!g) {
|
|
||||||
g = {
|
|
||||||
providerId: it.provider.id,
|
|
||||||
providerName: it.provider.name,
|
|
||||||
commissionPct: Number(it.provider.commissionPct),
|
|
||||||
lines: [],
|
|
||||||
itemsTotal: new Prisma.Decimal(0),
|
|
||||||
depositTotal: new Prisma.Decimal(0),
|
|
||||||
startDate: start,
|
|
||||||
endDate: end,
|
|
||||||
};
|
|
||||||
groups.set(it.provider.id, g);
|
|
||||||
}
|
|
||||||
g.lines.push({ itemId: it.id, qty: entry.qty, startDate: start, endDate: end, nights });
|
|
||||||
g.itemsTotal = g.itemsTotal.add(lineSub);
|
|
||||||
g.depositTotal = g.depositTotal.add(lineDeposit);
|
|
||||||
if (start < g.startDate) g.startDate = start;
|
|
||||||
if (end > g.endDate) g.endDate = end;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Transaction : recheck stock + crée RentalBookings + Lines + Availabilities
|
|
||||||
let grandTotal = new Prisma.Decimal(0);
|
|
||||||
let grandDeposit = new Prisma.Decimal(0);
|
|
||||||
let rentalBookingIds: string[] = [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
rentalBookingIds = await prisma.$transaction(async (tx) => {
|
|
||||||
const created: string[] = [];
|
|
||||||
|
|
||||||
for (const g of groups.values()) {
|
|
||||||
// Recheck stock disponible pour chaque ligne
|
|
||||||
for (const line of g.lines) {
|
|
||||||
const blocked = await tx.rentalItemAvailability.aggregate({
|
|
||||||
where: {
|
|
||||||
itemId: line.itemId,
|
|
||||||
startDate: { lt: line.endDate },
|
|
||||||
endDate: { gt: line.startDate },
|
|
||||||
},
|
|
||||||
_sum: { qty: true },
|
|
||||||
});
|
|
||||||
const item = itemById.get(line.itemId)!;
|
|
||||||
const used = Number(blocked._sum.qty ?? 0);
|
|
||||||
const free = item.totalQty - used;
|
|
||||||
if (line.qty > free) {
|
|
||||||
throw new Error(`Stock insuffisant pour « ${item.name} » sur les dates demandées (libre: ${free}).`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const commissionAmount = g.itemsTotal
|
|
||||||
.mul(g.commissionPct)
|
|
||||||
.div(100)
|
|
||||||
.toDecimalPlaces(2);
|
|
||||||
const amount = g.itemsTotal.add(g.depositTotal).toDecimalPlaces(2);
|
|
||||||
|
|
||||||
const rb = await tx.rentalBooking.create({
|
|
||||||
data: {
|
|
||||||
tenantId: session.user!.id!,
|
|
||||||
providerId: g.providerId,
|
|
||||||
startDate: g.startDate,
|
|
||||||
endDate: g.endDate,
|
|
||||||
status: RentalBookingStatus.PENDING,
|
|
||||||
paymentStatus: PaymentStatus.PENDING,
|
|
||||||
itemsTotal: g.itemsTotal.toDecimalPlaces(2),
|
|
||||||
depositTotal: g.depositTotal.toDecimalPlaces(2),
|
|
||||||
commissionAmount,
|
|
||||||
amount,
|
|
||||||
currency: "EUR",
|
|
||||||
lines: {
|
|
||||||
create: g.lines.map((line) => {
|
|
||||||
const item = itemById.get(line.itemId)!;
|
|
||||||
const lineTotal = new Prisma.Decimal(item.pricePerDay)
|
|
||||||
.mul(line.qty)
|
|
||||||
.mul(line.nights)
|
|
||||||
.toDecimalPlaces(2);
|
|
||||||
return {
|
|
||||||
itemId: line.itemId,
|
|
||||||
qty: line.qty,
|
|
||||||
pricePerDay: new Prisma.Decimal(item.pricePerDay),
|
|
||||||
deposit: new Prisma.Decimal(item.deposit),
|
|
||||||
lineTotal,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Bloque les dispos
|
|
||||||
for (const line of g.lines) {
|
|
||||||
await tx.rentalItemAvailability.create({
|
|
||||||
data: {
|
|
||||||
itemId: line.itemId,
|
|
||||||
startDate: line.startDate,
|
|
||||||
endDate: line.endDate,
|
|
||||||
qty: line.qty,
|
|
||||||
reason: "RENTAL_BOOKING",
|
|
||||||
rentalBookingId: rb.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
created.push(rb.id);
|
|
||||||
grandTotal = grandTotal.add(g.itemsTotal);
|
|
||||||
grandDeposit = grandDeposit.add(g.depositTotal);
|
|
||||||
}
|
|
||||||
|
|
||||||
return created;
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: e instanceof Error ? e.message : "Erreur lors de la création." },
|
|
||||||
{ status: 409 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalAmount = grandTotal.add(grandDeposit).toDecimalPlaces(2);
|
|
||||||
|
|
||||||
await recordAudit({
|
|
||||||
scope: "rental",
|
|
||||||
event: "rental.checkout.created",
|
|
||||||
target: rentalBookingIds.join(","),
|
|
||||||
actorEmail: session.user.email,
|
|
||||||
details: {
|
|
||||||
rentalBookingIds,
|
|
||||||
amount: totalAmount.toNumber(),
|
|
||||||
depositTotal: grandDeposit.toNumber(),
|
|
||||||
providers: Array.from(groups.keys()),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Emails best-effort : 1 mail au locataire (récap par prestataire) + 1 mail
|
|
||||||
// à chaque prestataire (sa demande). En cas d'échec d'envoi, on ne bloque pas.
|
|
||||||
try {
|
|
||||||
const fullBookings = await prisma.rentalBooking.findMany({
|
|
||||||
where: { id: { in: rentalBookingIds } },
|
|
||||||
include: {
|
|
||||||
provider: { select: { name: true, contactEmail: true } },
|
|
||||||
lines: { include: { item: { select: { name: true } } } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const tenantName = session.user.name ?? session.user.email!;
|
|
||||||
for (const rb of fullBookings) {
|
|
||||||
const lineSummary = rb.lines.map((l) => ({ qty: l.qty, itemName: l.item.name }));
|
|
||||||
await sendRentalRequestedTenant(
|
|
||||||
session.user.email!,
|
|
||||||
tenantName,
|
|
||||||
rb.id,
|
|
||||||
rb.provider.name,
|
|
||||||
rb.startDate,
|
|
||||||
rb.endDate,
|
|
||||||
rb.amount.toString(),
|
|
||||||
rb.currency,
|
|
||||||
lineSummary,
|
|
||||||
);
|
|
||||||
if (rb.provider.contactEmail) {
|
|
||||||
await sendRentalRequestedProvider(
|
|
||||||
rb.provider.contactEmail,
|
|
||||||
rb.provider.name,
|
|
||||||
rb.id,
|
|
||||||
tenantName,
|
|
||||||
rb.startDate,
|
|
||||||
rb.endDate,
|
|
||||||
lineSummary,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error("[rental.checkout] email send failed:", e instanceof Error ? e.message : e);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Vide le panier
|
|
||||||
jar.set(CART_COOKIE, JSON.stringify(EMPTY_CART), {
|
|
||||||
httpOnly: false,
|
|
||||||
sameSite: "lax",
|
|
||||||
path: "/",
|
|
||||||
maxAge: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Stripe ou paiement différé
|
|
||||||
if (!isStripeConfigured()) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ rentalBookingIds, totalAmount: totalAmount.toNumber() },
|
|
||||||
{ status: 201 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const appUrl = process.env.APP_URL;
|
|
||||||
if (!appUrl) {
|
|
||||||
return NextResponse.json({ error: "APP_URL manquante." }, { status: 500 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Une session Stripe avec une ligne par RentalBooking (agrégée)
|
|
||||||
const stripe = getStripeClient();
|
|
||||||
const bookingDetails = await prisma.rentalBooking.findMany({
|
|
||||||
where: { id: { in: rentalBookingIds } },
|
|
||||||
include: {
|
|
||||||
provider: { select: { name: true } },
|
|
||||||
lines: { select: { qty: true, item: { select: { name: true } } } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const line_items = bookingDetails.map((rb) => ({
|
|
||||||
quantity: 1,
|
|
||||||
price_data: {
|
|
||||||
currency: "eur",
|
|
||||||
unit_amount: toStripeAmountCents(Number(rb.amount)),
|
|
||||||
product_data: {
|
|
||||||
name: `Matériel — ${rb.provider.name}`,
|
|
||||||
description: rb.lines.map((l) => `${l.qty}× ${l.item.name}`).join(", ").slice(0, 500),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const checkoutSession = await stripe.checkout.sessions.create({
|
|
||||||
mode: "payment",
|
|
||||||
success_url: `${appUrl}/mes-locations?payment=success&ids=${rentalBookingIds.join(",")}`,
|
|
||||||
cancel_url: `${appUrl}/panier?payment=cancel`,
|
|
||||||
customer_email: session.user.email,
|
|
||||||
line_items,
|
|
||||||
metadata: {
|
|
||||||
type: "rental-bundle",
|
|
||||||
rentalBookingIds: rentalBookingIds.join(","),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await prisma.rentalBooking.updateMany({
|
|
||||||
where: { id: { in: rentalBookingIds } },
|
|
||||||
data: { stripeSessionId: checkoutSession.id },
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
rentalBookingIds,
|
|
||||||
totalAmount: totalAmount.toNumber(),
|
|
||||||
checkoutSessionId: checkoutSession.id,
|
|
||||||
checkoutUrl: checkoutSession.url,
|
|
||||||
},
|
|
||||||
{ status: 201 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import type { NextRequest } from "next/server";
|
|
||||||
|
|
||||||
import { getItemAvailability } from "@/lib/rentals-public";
|
|
||||||
import { parseIsoDate, normalizeUtcDayStart } from "@/lib/booking";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
|
|
||||||
export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
|
|
||||||
const { id } = await ctx.params;
|
|
||||||
const from = parseIsoDate(req.nextUrl.searchParams.get("from"));
|
|
||||||
const to = parseIsoDate(req.nextUrl.searchParams.get("to"));
|
|
||||||
if (!from || !to) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Paramètres from et to (YYYY-MM-DD) requis." },
|
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const start = normalizeUtcDayStart(from);
|
|
||||||
const end = normalizeUtcDayStart(to);
|
|
||||||
if (end <= start) {
|
|
||||||
return NextResponse.json({ error: "to doit être > from." }, { status: 400 });
|
|
||||||
}
|
|
||||||
const calendar = await getItemAvailability(id, start, end);
|
|
||||||
return NextResponse.json({
|
|
||||||
itemId: id,
|
|
||||||
from: start.toISOString(),
|
|
||||||
to: end.toISOString(),
|
|
||||||
calendar,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,199 +0,0 @@
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
import { UserRole } from "@/generated/prisma/enums";
|
|
||||||
import { getOrgInviteByToken, markOrgInviteConsumed } from "@/lib/ce-invites";
|
|
||||||
import { hashPassword } from "@/lib/password";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { recordAudit } from "@/lib/admin/audit";
|
|
||||||
import { sendNewCeRequest, sendNewRentalProviderRequest, sendSignupWelcome } from "@/lib/email";
|
|
||||||
import { rateLimitRequest } from "@/lib/rate-limit";
|
|
||||||
import { slugify } from "@/lib/slug";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
|
|
||||||
const schema = z.object({
|
|
||||||
email: z.string().trim().toLowerCase().email().max(200),
|
|
||||||
password: z.string().min(8).max(200),
|
|
||||||
firstName: z.string().trim().min(1).max(100),
|
|
||||||
lastName: z.string().trim().min(1).max(100),
|
|
||||||
phone: z.string().trim().max(40).optional().nullable(),
|
|
||||||
role: z
|
|
||||||
.enum([UserRole.TOURIST, UserRole.OWNER, UserRole.RENTAL_PROVIDER, UserRole.CE_MANAGER])
|
|
||||||
.default(UserRole.TOURIST),
|
|
||||||
providerName: z.string().trim().min(2).max(200).optional(),
|
|
||||||
providerRivers: z.array(z.string().trim().min(1).max(80)).max(20).optional(),
|
|
||||||
orgName: z.string().trim().min(2).max(200).optional(),
|
|
||||||
inviteToken: z.string().trim().min(8).max(200).optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
|
||||||
const rl = rateLimitRequest(req, "signup", 60 * 60 * 1000, 5);
|
|
||||||
if (!rl.ok) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: `Trop de tentatives. Réessayez dans ${rl.retryAfter}s.` },
|
|
||||||
{ status: 429, headers: { "Retry-After": String(rl.retryAfter) } },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let body: unknown;
|
|
||||||
try {
|
|
||||||
body = await req.json();
|
|
||||||
} catch {
|
|
||||||
return NextResponse.json({ error: "Corps JSON invalide." }, { status: 400 });
|
|
||||||
}
|
|
||||||
const parsed = schema.safeParse(body);
|
|
||||||
if (!parsed.success) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") },
|
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const data = parsed.data;
|
|
||||||
|
|
||||||
if (data.role === UserRole.RENTAL_PROVIDER && (!data.providerName || data.providerName.trim().length < 2)) {
|
|
||||||
return NextResponse.json({ error: "Nom de votre activité requis." }, { status: 400 });
|
|
||||||
}
|
|
||||||
if (data.role === UserRole.CE_MANAGER && (!data.orgName || data.orgName.trim().length < 2)) {
|
|
||||||
return NextResponse.json({ error: "Nom de votre Comité d'Entreprise requis." }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Invitation CE_MEMBER : si un inviteToken est fourni, on force le rôle CE_MEMBER
|
|
||||||
// et on rattache à l'org du token (org déjà validée — pas de bannière pending).
|
|
||||||
let inviteOrgId: string | null = null;
|
|
||||||
if (data.inviteToken) {
|
|
||||||
const invite = await getOrgInviteByToken(data.inviteToken);
|
|
||||||
if (!invite) {
|
|
||||||
return NextResponse.json({ error: "Lien d'invitation invalide ou expiré." }, { status: 400 });
|
|
||||||
}
|
|
||||||
if (invite.email && invite.email.toLowerCase() !== data.email) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Ce lien d'invitation est réservé à un autre email." },
|
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
inviteOrgId = invite.organizationId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const existing = await prisma.user.findUnique({ where: { email: data.email }, select: { id: true } });
|
|
||||||
if (existing) {
|
|
||||||
return NextResponse.json({ error: "Un compte existe déjà avec cet email." }, { status: 409 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const passwordHash = await hashPassword(data.password);
|
|
||||||
|
|
||||||
// CE_MANAGER : transaction atomique User + Organization. Le slug est unique
|
|
||||||
// sur Organization → on retente avec un suffixe en cas de collision.
|
|
||||||
let createdProviderId: string | null = null;
|
|
||||||
let createdOrgId: string | null = null;
|
|
||||||
let user: { id: string; email: string; role: UserRole };
|
|
||||||
|
|
||||||
if (inviteOrgId) {
|
|
||||||
// Branche invite CE_MEMBER : rattache le user à l'org du token, ignore data.role.
|
|
||||||
user = await prisma.user.create({
|
|
||||||
data: {
|
|
||||||
email: data.email,
|
|
||||||
passwordHash,
|
|
||||||
firstName: data.firstName,
|
|
||||||
lastName: data.lastName,
|
|
||||||
phone: data.phone?.trim() || null,
|
|
||||||
role: UserRole.CE_MEMBER,
|
|
||||||
organizationId: inviteOrgId,
|
|
||||||
isActive: true,
|
|
||||||
},
|
|
||||||
select: { id: true, email: true, role: true },
|
|
||||||
});
|
|
||||||
createdOrgId = inviteOrgId;
|
|
||||||
await markOrgInviteConsumed(data.inviteToken!).catch(() => {});
|
|
||||||
} else if (data.role === UserRole.CE_MANAGER) {
|
|
||||||
const orgName = data.orgName!.trim();
|
|
||||||
const baseSlug = slugify(orgName);
|
|
||||||
const result = await prisma.$transaction(async (tx) => {
|
|
||||||
// Trouve un slug libre
|
|
||||||
let candidate = baseSlug || "ce";
|
|
||||||
let suffix = 1;
|
|
||||||
for (;;) {
|
|
||||||
const exists = await tx.organization.findUnique({ where: { slug: candidate }, select: { id: true } });
|
|
||||||
if (!exists) break;
|
|
||||||
suffix += 1;
|
|
||||||
candidate = `${baseSlug}-${suffix}`;
|
|
||||||
}
|
|
||||||
// candidate now holds a free slug
|
|
||||||
const org = await tx.organization.create({
|
|
||||||
data: {
|
|
||||||
name: orgName,
|
|
||||||
slug: candidate,
|
|
||||||
contactEmail: data.email,
|
|
||||||
approved: false,
|
|
||||||
},
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
const u = await tx.user.create({
|
|
||||||
data: {
|
|
||||||
email: data.email,
|
|
||||||
passwordHash,
|
|
||||||
firstName: data.firstName,
|
|
||||||
lastName: data.lastName,
|
|
||||||
phone: data.phone?.trim() || null,
|
|
||||||
role: UserRole.CE_MANAGER,
|
|
||||||
organizationId: org.id,
|
|
||||||
isActive: true,
|
|
||||||
},
|
|
||||||
select: { id: true, email: true, role: true },
|
|
||||||
});
|
|
||||||
return { user: u, orgId: org.id };
|
|
||||||
});
|
|
||||||
user = result.user;
|
|
||||||
createdOrgId = result.orgId;
|
|
||||||
sendNewCeRequest(orgName, user.email).catch(() => {});
|
|
||||||
} else {
|
|
||||||
user = await prisma.user.create({
|
|
||||||
data: {
|
|
||||||
email: data.email,
|
|
||||||
passwordHash,
|
|
||||||
firstName: data.firstName,
|
|
||||||
lastName: data.lastName,
|
|
||||||
phone: data.phone?.trim() || null,
|
|
||||||
role: data.role,
|
|
||||||
isActive: true,
|
|
||||||
},
|
|
||||||
select: { id: true, email: true, role: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Pour un RENTAL_PROVIDER : crée le RentalProvider associé en attente d'approbation.
|
|
||||||
if (user.role === UserRole.RENTAL_PROVIDER && data.providerName) {
|
|
||||||
const provider = await prisma.rentalProvider.create({
|
|
||||||
data: {
|
|
||||||
name: data.providerName,
|
|
||||||
isSystemD: false,
|
|
||||||
managedByUserId: user.id,
|
|
||||||
contactEmail: user.email,
|
|
||||||
contactPhone: data.phone?.trim() || null,
|
|
||||||
rivers: data.providerRivers ?? [],
|
|
||||||
commissionPct: 10, // valeur par défaut, ajustable par admin
|
|
||||||
active: true,
|
|
||||||
approved: false,
|
|
||||||
},
|
|
||||||
select: { id: true, name: true },
|
|
||||||
});
|
|
||||||
createdProviderId = provider.id;
|
|
||||||
sendNewRentalProviderRequest(provider.name, user.email).catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await recordAudit({
|
|
||||||
scope: "public.signup",
|
|
||||||
event: "user.create",
|
|
||||||
target: user.id,
|
|
||||||
actorEmail: user.email,
|
|
||||||
details: { role: user.role, rentalProviderId: createdProviderId, organizationId: createdOrgId },
|
|
||||||
});
|
|
||||||
|
|
||||||
sendSignupWelcome(user.email, data.firstName).catch(() => {});
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
ok: true,
|
|
||||||
userId: user.id,
|
|
||||||
providerId: createdProviderId,
|
|
||||||
organizationId: createdOrgId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -4,11 +4,9 @@ import Stripe from "stripe";
|
||||||
import {
|
import {
|
||||||
BookingStatus,
|
BookingStatus,
|
||||||
PaymentStatus,
|
PaymentStatus,
|
||||||
RentalBookingStatus,
|
|
||||||
SubscriptionStatus,
|
SubscriptionStatus,
|
||||||
} from "@/generated/prisma/enums";
|
} from "@/generated/prisma/enums";
|
||||||
import { refreshCarbetLastBookedAt } from "@/lib/carbet-last-booked";
|
import { refreshCarbetLastBookedAt } from "@/lib/carbet-last-booked";
|
||||||
import { sendRentalConfirmed } from "@/lib/email";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { fromStripeTimestamp, getStripeClient } from "@/lib/stripe";
|
import { fromStripeTimestamp, getStripeClient } from "@/lib/stripe";
|
||||||
|
|
||||||
|
|
@ -53,43 +51,6 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === "rental-bundle") {
|
|
||||||
const idsRaw = session.metadata?.rentalBookingIds;
|
|
||||||
if (!idsRaw) return;
|
|
||||||
const ids = idsRaw.split(",").map((s) => s.trim()).filter(Boolean);
|
|
||||||
if (ids.length === 0) return;
|
|
||||||
await prisma.rentalBooking.updateMany({
|
|
||||||
where: { id: { in: ids } },
|
|
||||||
data: {
|
|
||||||
paymentStatus: PaymentStatus.SUCCEEDED,
|
|
||||||
status: RentalBookingStatus.CONFIRMED,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
const rentals = await prisma.rentalBooking.findMany({
|
|
||||||
where: { id: { in: ids } },
|
|
||||||
include: {
|
|
||||||
provider: { select: { name: true } },
|
|
||||||
tenant: { select: { email: true, firstName: true } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
for (const rb of rentals) {
|
|
||||||
if (!rb.tenant.email) continue;
|
|
||||||
await sendRentalConfirmed(
|
|
||||||
rb.tenant.email,
|
|
||||||
rb.tenant.firstName ?? rb.tenant.email,
|
|
||||||
rb.id,
|
|
||||||
rb.provider.name,
|
|
||||||
rb.startDate,
|
|
||||||
rb.endDate,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error("[webhook.rental] email send failed:", e instanceof Error ? e.message : e);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "owner_subscription") {
|
if (type === "owner_subscription") {
|
||||||
const ownerId = session.metadata?.ownerId;
|
const ownerId = session.metadata?.ownerId;
|
||||||
const carbetId = session.metadata?.carbetId;
|
const carbetId = session.metadata?.carbetId;
|
||||||
|
|
@ -118,27 +79,6 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
|
||||||
|
|
||||||
async function handlePaymentIntentFailed(paymentIntent: Stripe.PaymentIntent) {
|
async function handlePaymentIntentFailed(paymentIntent: Stripe.PaymentIntent) {
|
||||||
const bookingId = paymentIntent.metadata?.bookingId;
|
const bookingId = paymentIntent.metadata?.bookingId;
|
||||||
const rentalIdsRaw = paymentIntent.metadata?.rentalBookingIds;
|
|
||||||
|
|
||||||
if (rentalIdsRaw) {
|
|
||||||
const ids = rentalIdsRaw.split(",").map((s) => s.trim()).filter(Boolean);
|
|
||||||
if (ids.length > 0) {
|
|
||||||
// Marque les paiements échoués + libère les blocages de dispo
|
|
||||||
await prisma.$transaction([
|
|
||||||
prisma.rentalBooking.updateMany({
|
|
||||||
where: { id: { in: ids } },
|
|
||||||
data: {
|
|
||||||
paymentStatus: PaymentStatus.FAILED,
|
|
||||||
status: RentalBookingStatus.CANCELLED,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
prisma.rentalItemAvailability.deleteMany({
|
|
||||||
where: { rentalBookingId: { in: ids } },
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!bookingId) {
|
if (!bookingId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,89 +0,0 @@
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
import { auth } from "@/auth";
|
|
||||||
import { MediaType, UserRole } from "@/generated/prisma/enums";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { classifyMime } from "@/lib/uploads";
|
|
||||||
import { recordAudit } from "@/lib/admin/audit";
|
|
||||||
import { generateImageVariants } from "@/lib/variants-server";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
|
|
||||||
const schema = z.object({
|
|
||||||
carbetId: z.string().min(1),
|
|
||||||
s3Key: z.string().min(5).max(500),
|
|
||||||
s3Url: z.string().url(),
|
|
||||||
mime: z.string().min(3).max(100),
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user?.id) {
|
|
||||||
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
|
|
||||||
}
|
|
||||||
const parsed = schema.safeParse(await req.json().catch(() => ({})));
|
|
||||||
if (!parsed.success) {
|
|
||||||
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Payload invalide" }, { status: 400 });
|
|
||||||
}
|
|
||||||
const kind = classifyMime(parsed.data.mime);
|
|
||||||
if (!kind) return NextResponse.json({ error: "Type non supporté" }, { status: 400 });
|
|
||||||
|
|
||||||
const carbet = await prisma.carbet.findUnique({
|
|
||||||
where: { id: parsed.data.carbetId },
|
|
||||||
select: { id: true, ownerId: true },
|
|
||||||
});
|
|
||||||
if (!carbet) return NextResponse.json({ error: "Carbet introuvable" }, { status: 404 });
|
|
||||||
const isOwner = carbet.ownerId === session.user.id;
|
|
||||||
const isAdmin = session.user.role === UserRole.ADMIN;
|
|
||||||
if (!isOwner && !isAdmin) {
|
|
||||||
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// S3Key doit appartenir au carbet — verrou pour éviter qu'un user finalise une key étrangère.
|
|
||||||
if (!parsed.data.s3Key.startsWith(`carbets/${carbet.id}/`)) {
|
|
||||||
return NextResponse.json({ error: "s3Key invalide pour ce carbet" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingCount = await prisma.media.count({ where: { carbetId: carbet.id } });
|
|
||||||
const media = await prisma.media.create({
|
|
||||||
data: {
|
|
||||||
carbetId: carbet.id,
|
|
||||||
type: kind === "photo" ? MediaType.PHOTO : MediaType.VIDEO,
|
|
||||||
s3Key: parsed.data.s3Key,
|
|
||||||
s3Url: parsed.data.s3Url,
|
|
||||||
sortOrder: existingCount,
|
|
||||||
},
|
|
||||||
select: { id: true, type: true, s3Url: true, s3Key: true, sortOrder: true },
|
|
||||||
});
|
|
||||||
await recordAudit({
|
|
||||||
scope: "uploads",
|
|
||||||
event: "media.finalize",
|
|
||||||
target: media.id,
|
|
||||||
actorEmail: session.user.email ?? null,
|
|
||||||
details: { carbetId: carbet.id, kind },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Génération des variantes responsives (best-effort, n'échoue pas la requête).
|
|
||||||
// L'utilisateur attend quelques secondes mais l'expérience derrière est bien meilleure.
|
|
||||||
try {
|
|
||||||
const variants = await generateImageVariants({
|
|
||||||
originalS3Key: parsed.data.s3Key,
|
|
||||||
mime: parsed.data.mime,
|
|
||||||
});
|
|
||||||
if (!variants.skipped) {
|
|
||||||
const okCount = variants.results.filter((r) => r.ok).length;
|
|
||||||
await recordAudit({
|
|
||||||
scope: "uploads",
|
|
||||||
event: "media.variants",
|
|
||||||
target: media.id,
|
|
||||||
actorEmail: session.user.email ?? null,
|
|
||||||
details: { generated: okCount, total: variants.results.length },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error("[uploads] variants generation error:", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ media });
|
|
||||||
}
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
import { auth } from "@/auth";
|
|
||||||
import { UserRole } from "@/generated/prisma/enums";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { presignCarbetUpload } from "@/lib/uploads";
|
|
||||||
import { rateLimitRequest } from "@/lib/rate-limit";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
|
|
||||||
const schema = z.object({
|
|
||||||
carbetId: z.string().min(1),
|
|
||||||
mime: z.string().min(3).max(100),
|
|
||||||
sizeBytes: z.coerce.number().int().min(1).max(500 * 1024 * 1024),
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
|
||||||
const rl = rateLimitRequest(req, "presign", 60_000, 60);
|
|
||||||
if (!rl.ok) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: `Trop de demandes. Réessayez dans ${rl.retryAfter}s.` },
|
|
||||||
{ status: 429 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user?.id) {
|
|
||||||
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
|
|
||||||
}
|
|
||||||
const parsed = schema.safeParse(await req.json().catch(() => ({})));
|
|
||||||
if (!parsed.success) {
|
|
||||||
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Payload invalide" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const carbet = await prisma.carbet.findUnique({
|
|
||||||
where: { id: parsed.data.carbetId },
|
|
||||||
select: { id: true, ownerId: true },
|
|
||||||
});
|
|
||||||
if (!carbet) return NextResponse.json({ error: "Carbet introuvable" }, { status: 404 });
|
|
||||||
const isOwner = carbet.ownerId === session.user.id;
|
|
||||||
const isAdmin = session.user.role === UserRole.ADMIN;
|
|
||||||
if (!isOwner && !isAdmin) {
|
|
||||||
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await presignCarbetUpload({
|
|
||||||
carbetId: carbet.id,
|
|
||||||
mime: parsed.data.mime,
|
|
||||||
sizeBytes: parsed.data.sizeBytes,
|
|
||||||
});
|
|
||||||
if ("error" in result) {
|
|
||||||
return NextResponse.json({ error: result.error }, { status: 400 });
|
|
||||||
}
|
|
||||||
return NextResponse.json(result);
|
|
||||||
}
|
|
||||||
|
|
@ -1,105 +0,0 @@
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
import { auth } from "@/auth";
|
|
||||||
import { MediaType } from "@/generated/prisma/enums";
|
|
||||||
import { recordAudit } from "@/lib/admin/audit";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { canManageRentalProvider } from "@/lib/rental-access";
|
|
||||||
import { classifyMime } from "@/lib/uploads";
|
|
||||||
import { generateImageVariants } from "@/lib/variants-server";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
|
|
||||||
const schema = z.object({
|
|
||||||
itemId: z.string().min(1),
|
|
||||||
s3Key: z.string().min(5).max(500),
|
|
||||||
s3Url: z.string().url(),
|
|
||||||
mime: z.string().min(3).max(100),
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user?.id) {
|
|
||||||
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
|
|
||||||
}
|
|
||||||
const parsed = schema.safeParse(await req.json().catch(() => ({})));
|
|
||||||
if (!parsed.success) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: parsed.error.issues[0]?.message ?? "Payload invalide" },
|
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const kind = classifyMime(parsed.data.mime);
|
|
||||||
if (!kind) return NextResponse.json({ error: "Type non supporté" }, { status: 400 });
|
|
||||||
|
|
||||||
const item = await prisma.rentalItem.findUnique({
|
|
||||||
where: { id: parsed.data.itemId },
|
|
||||||
select: { id: true, providerId: true },
|
|
||||||
});
|
|
||||||
if (!item) return NextResponse.json({ error: "Item introuvable" }, { status: 404 });
|
|
||||||
|
|
||||||
const allowed = await canManageRentalProvider(
|
|
||||||
session.user.id,
|
|
||||||
session.user.role,
|
|
||||||
item.providerId,
|
|
||||||
session.user.organizationId,
|
|
||||||
);
|
|
||||||
if (!allowed) {
|
|
||||||
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!parsed.data.s3Key.startsWith(`rental-items/${item.id}/`)) {
|
|
||||||
return NextResponse.json({ error: "s3Key invalide pour cet item" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingCount = await prisma.rentalItemMedia.count({ where: { itemId: item.id } });
|
|
||||||
const media = await prisma.rentalItemMedia.create({
|
|
||||||
data: {
|
|
||||||
itemId: item.id,
|
|
||||||
type: kind === "photo" ? MediaType.PHOTO : MediaType.VIDEO,
|
|
||||||
s3Key: parsed.data.s3Key,
|
|
||||||
s3Url: parsed.data.s3Url,
|
|
||||||
sortOrder: existingCount,
|
|
||||||
},
|
|
||||||
select: { id: true, type: true, s3Url: true, s3Key: true, sortOrder: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Si c'est la première photo de l'item, hydrate imageUrl pour rétro-compat
|
|
||||||
// avec les listings (RentalItemCard, /carbets/[slug] panel).
|
|
||||||
if (existingCount === 0 && kind === "photo") {
|
|
||||||
await prisma.rentalItem.update({
|
|
||||||
where: { id: item.id },
|
|
||||||
data: { imageUrl: parsed.data.s3Url },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await recordAudit({
|
|
||||||
scope: "uploads",
|
|
||||||
event: "rental.media.finalize",
|
|
||||||
target: media.id,
|
|
||||||
actorEmail: session.user.email ?? null,
|
|
||||||
details: { itemId: item.id, kind },
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const variants = await generateImageVariants({
|
|
||||||
originalS3Key: parsed.data.s3Key,
|
|
||||||
mime: parsed.data.mime,
|
|
||||||
});
|
|
||||||
if (!variants.skipped) {
|
|
||||||
const okCount = variants.results.filter((r) => r.ok).length;
|
|
||||||
await recordAudit({
|
|
||||||
scope: "uploads",
|
|
||||||
event: "rental.media.variants",
|
|
||||||
target: media.id,
|
|
||||||
actorEmail: session.user.email ?? null,
|
|
||||||
details: { generated: okCount, total: variants.results.length },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error("[rental-uploads] variants generation error:", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ media });
|
|
||||||
}
|
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
import { auth } from "@/auth";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { canManageRentalProvider } from "@/lib/rental-access";
|
|
||||||
import { rateLimitRequest } from "@/lib/rate-limit";
|
|
||||||
import { presignRentalItemUpload } from "@/lib/uploads";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
|
|
||||||
const schema = z.object({
|
|
||||||
itemId: z.string().min(1),
|
|
||||||
mime: z.string().min(3).max(100),
|
|
||||||
sizeBytes: z.coerce.number().int().min(1).max(500 * 1024 * 1024),
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
|
||||||
const rl = rateLimitRequest(req, "rental-presign", 60_000, 60);
|
|
||||||
if (!rl.ok) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: `Trop de demandes. Réessayez dans ${rl.retryAfter}s.` },
|
|
||||||
{ status: 429 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user?.id) {
|
|
||||||
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
|
|
||||||
}
|
|
||||||
const parsed = schema.safeParse(await req.json().catch(() => ({})));
|
|
||||||
if (!parsed.success) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: parsed.error.issues[0]?.message ?? "Payload invalide" },
|
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const item = await prisma.rentalItem.findUnique({
|
|
||||||
where: { id: parsed.data.itemId },
|
|
||||||
select: { id: true, providerId: true },
|
|
||||||
});
|
|
||||||
if (!item) return NextResponse.json({ error: "Item introuvable" }, { status: 404 });
|
|
||||||
|
|
||||||
const allowed = await canManageRentalProvider(
|
|
||||||
session.user.id,
|
|
||||||
session.user.role,
|
|
||||||
item.providerId,
|
|
||||||
session.user.organizationId,
|
|
||||||
);
|
|
||||||
if (!allowed) {
|
|
||||||
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await presignRentalItemUpload({
|
|
||||||
itemId: item.id,
|
|
||||||
mime: parsed.data.mime,
|
|
||||||
sizeBytes: parsed.data.sizeBytes,
|
|
||||||
});
|
|
||||||
if ("error" in result) {
|
|
||||||
return NextResponse.json({ error: result.error }, { status: 400 });
|
|
||||||
}
|
|
||||||
return NextResponse.json(result);
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue