Compare commits
No commits in common. "main" and "feat/payment-stripe" have entirely different histories.
main
...
feat/payme
263 changed files with 346 additions and 23532 deletions
|
|
@ -10,12 +10,6 @@ AUTH_SECRET="changeme"
|
|||
# URL publique du site, utilisée pour résoudre les URLs canoniques et
|
||||
# OpenGraph (SEO). En développement, laissez la valeur par défaut.
|
||||
NEXT_PUBLIC_SITE_URL="http://localhost:3000"
|
||||
APP_URL="http://localhost:3000"
|
||||
|
||||
# Stripe (mode test recommandé pour le MVP)
|
||||
STRIPE_SECRET_KEY="sk_test_xxx"
|
||||
STRIPE_WEBHOOK_SECRET="whsec_xxx"
|
||||
STRIPE_OWNER_SUBSCRIPTION_PRICE_ID="price_xxx"
|
||||
|
||||
# Stockage objet des médias (S3 ou MinIO). Compatible AWS S3 et MinIO.
|
||||
# Pour MinIO en local : renseignez S3_ENDPOINT (ex: http://localhost:9000)
|
||||
|
|
|
|||
|
|
@ -1,36 +0,0 @@
|
|||
NODE_ENV=production
|
||||
PORT=3000
|
||||
|
||||
# Domain
|
||||
NEXT_PUBLIC_SITE_URL=https://karbe.cosmolan.fr
|
||||
APP_URL=https://karbe.cosmolan.fr
|
||||
|
||||
# Security
|
||||
NEXTAUTH_SECRET=replace_with_random_secret
|
||||
AUTH_SECRET=replace_with_random_secret
|
||||
|
||||
# Co-deployed PostgreSQL (docker-compose.prod.yml)
|
||||
POSTGRES_DB=karbe
|
||||
POSTGRES_USER=karbe
|
||||
POSTGRES_PASSWORD=replace_with_strong_password
|
||||
DATABASE_URL=postgresql://karbe:replace_with_strong_password@postgres:5432/karbe?schema=public
|
||||
|
||||
# Stripe TEST
|
||||
STRIPE_SECRET_KEY=sk_test_xxx
|
||||
STRIPE_WEBHOOK_SECRET=whsec_xxx
|
||||
STRIPE_OWNER_SUBSCRIPTION_PRICE_ID=price_xxx
|
||||
|
||||
# Co-deployed MinIO (docker-compose.prod.yml)
|
||||
MINIO_ROOT_USER=karbe
|
||||
MINIO_ROOT_PASSWORD=replace_with_strong_password
|
||||
S3_ENDPOINT=http://minio:9000
|
||||
S3_REGION=us-east-1
|
||||
S3_BUCKET=karbe-medias
|
||||
S3_ACCESS_KEY_ID=karbe
|
||||
S3_SECRET_ACCESS_KEY=replace_with_strong_password
|
||||
S3_PUBLIC_URL=
|
||||
S3_FORCE_PATH_STYLE=true
|
||||
|
||||
# Recommended for stable multi-instance deploys
|
||||
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY=replace_with_base64_32_bytes
|
||||
DEPLOYMENT_VERSION=manual-v1
|
||||
|
|
@ -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"
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -33,7 +33,6 @@ yarn-error.log*
|
|||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
!.env.example
|
||||
!.env.production.example
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
|
|
|||
37
Dockerfile
37
Dockerfile
|
|
@ -1,37 +0,0 @@
|
|||
FROM node:20-alpine AS base
|
||||
WORKDIR /app
|
||||
|
||||
FROM base AS deps
|
||||
# Le postinstall de Prisma a besoin de prisma/schema.prisma pour `prisma generate`.
|
||||
# On copie donc le dossier prisma avant `npm ci`, sinon le hook crashe.
|
||||
COPY package.json package-lock.json ./
|
||||
COPY prisma ./prisma
|
||||
RUN npm ci
|
||||
|
||||
FROM base AS builder
|
||||
ENV NODE_ENV=production
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
# Régénère le client Prisma dans src/generated/prisma (le post-install de l'étape
|
||||
# deps l'a fait dans deps:/app/src/generated qu'on n'embarque pas). Sans cette
|
||||
# ligne, `next build` ne trouve pas le type `prisma.plugin` et autres.
|
||||
RUN npx prisma generate
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-alpine AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
|
||||
RUN addgroup -S nextjs && adduser -S nextjs -G nextjs
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
# Prisma schema + migrations dispo dans l'image runner pour `prisma migrate deploy`
|
||||
COPY --from=builder /app/prisma ./prisma
|
||||
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
65
README.md
65
README.md
|
|
@ -15,7 +15,6 @@ et payer leur séjour en ligne.
|
|||
- [Prérequis](#prérequis)
|
||||
- [Installation](#installation)
|
||||
- [Variables d'environnement](#variables-denvironnement)
|
||||
- [Déploiement production (karbe.cosmolan.fr)](#déploiement-production-karbecosmolanfr)
|
||||
- [Développement](#développement)
|
||||
- [Base de données (Prisma)](#base-de-données-prisma)
|
||||
- [Scripts npm](#scripts-npm)
|
||||
|
|
@ -116,75 +115,11 @@ Copiez-le en `.env` et renseignez vos valeurs.
|
|||
| --- | --- |
|
||||
| `DATABASE_URL` | Chaîne de connexion PostgreSQL utilisée par Prisma. |
|
||||
| `AUTH_SECRET` / `NEXTAUTH_SECRET` | Secret de signature des sessions NextAuth. Générer avec `openssl rand -base64 32`. |
|
||||
| `NEXT_PUBLIC_SITE_URL` / `APP_URL` | URL publique de l'application (ex: `https://karbe.cosmolan.fr`). |
|
||||
| `STRIPE_SECRET_KEY` | Clé secrète Stripe (mode test pour MVP). |
|
||||
| `STRIPE_WEBHOOK_SECRET` | Secret du webhook Stripe. |
|
||||
| `STRIPE_OWNER_SUBSCRIPTION_PRICE_ID` | Price ID de l'abonnement loueur Stripe. |
|
||||
|
||||
> Le fichier `.env` ne doit **jamais** être commité (il est ignoré par Git).
|
||||
> Au fur et à mesure de l'ajout des intégrations (ex. Stripe), de nouvelles
|
||||
> variables seront ajoutées à `.env.example`.
|
||||
|
||||
## Déploiement production (karbe.cosmolan.fr)
|
||||
|
||||
Le repo inclut une stack de self-hosting Docker pour le MVP:
|
||||
|
||||
- `Dockerfile` (build Next.js standalone)
|
||||
- `docker-compose.prod.yml` (app + PostgreSQL + MinIO + reverse proxy Caddy HTTPS)
|
||||
- `docker/Caddyfile`
|
||||
- `.env.production.example`
|
||||
|
||||
### 1) Préparer le serveur
|
||||
|
||||
- Docker Engine + Docker Compose plugin installés.
|
||||
- DNS `A`/`AAAA` de `karbe.cosmolan.fr` pointant vers le serveur.
|
||||
- Ports `80` et `443` ouverts.
|
||||
|
||||
### 2) Configurer l'environnement
|
||||
|
||||
```bash
|
||||
cp .env.production.example .env.production
|
||||
```
|
||||
|
||||
Renseigner toutes les variables, en particulier:
|
||||
`POSTGRES_PASSWORD`, `MINIO_ROOT_PASSWORD`,
|
||||
`DATABASE_URL`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`,
|
||||
`STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `STRIPE_OWNER_SUBSCRIPTION_PRICE_ID`,
|
||||
`APP_URL` et `NEXT_PUBLIC_SITE_URL`.
|
||||
|
||||
`docker-compose.prod.yml` crée automatiquement le bucket MinIO défini par
|
||||
`S3_BUCKET` au démarrage via le service `minio-init`.
|
||||
|
||||
### 3) Appliquer les migrations Prisma
|
||||
|
||||
Les migrations doivent être appliquées avant le premier démarrage :
|
||||
|
||||
```bash
|
||||
npx prisma migrate deploy
|
||||
```
|
||||
|
||||
### 4) Lancer la stack
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.yml up -d --build
|
||||
```
|
||||
|
||||
Vérifier la santé:
|
||||
|
||||
```bash
|
||||
curl -fsS https://karbe.cosmolan.fr/api/health
|
||||
```
|
||||
|
||||
### 5) Connecter le webhook Stripe (TEST)
|
||||
|
||||
Depuis un poste ayant Stripe CLI:
|
||||
|
||||
```bash
|
||||
stripe listen --forward-to https://karbe.cosmolan.fr/api/stripe/webhook
|
||||
```
|
||||
|
||||
Copier le secret affiché (`whsec_...`) dans `STRIPE_WEBHOOK_SECRET`, puis redéployer.
|
||||
|
||||
## Développement
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -1,108 +0,0 @@
|
|||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: karbe-postgres
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-karbe}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-karbe}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 20s
|
||||
networks:
|
||||
- karbe
|
||||
|
||||
minio:
|
||||
image: minio/minio:RELEASE.2026-05-24T17-08-30Z
|
||||
container_name: karbe-minio
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-karbe}
|
||||
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD must be set}
|
||||
command: server /data --console-address ":9001"
|
||||
volumes:
|
||||
- minio_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:9000/minio/health/live"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 20s
|
||||
networks:
|
||||
- karbe
|
||||
|
||||
minio-init:
|
||||
image: minio/mc:RELEASE.2026-05-21T01-59-54Z
|
||||
container_name: karbe-minio-init
|
||||
depends_on:
|
||||
minio:
|
||||
condition: service_healthy
|
||||
restart: "no"
|
||||
entrypoint: >
|
||||
/bin/sh -c "
|
||||
mc alias set karbe http://minio:9000 $$MINIO_ROOT_USER $$MINIO_ROOT_PASSWORD &&
|
||||
mc mb -p karbe/$$S3_BUCKET || true
|
||||
"
|
||||
environment:
|
||||
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-karbe}
|
||||
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD must be set}
|
||||
S3_BUCKET: ${S3_BUCKET:-karbe-medias}
|
||||
networks:
|
||||
- karbe
|
||||
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: karbe-app
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env.production
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
minio:
|
||||
condition: service_healthy
|
||||
minio-init:
|
||||
condition: service_completed_successfully
|
||||
networks:
|
||||
- karbe
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO", "-", "http://127.0.0.1:3000/api/health"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
|
||||
caddy:
|
||||
image: caddy:2.10-alpine
|
||||
container_name: karbe-caddy
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
depends_on:
|
||||
app:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ./docker/Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- caddy_data:/data
|
||||
- caddy_config:/config
|
||||
networks:
|
||||
- karbe
|
||||
|
||||
networks:
|
||||
karbe:
|
||||
name: karbe-net
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
minio_data:
|
||||
caddy_data:
|
||||
caddy_config:
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
{
|
||||
email ops@cosmolan.fr
|
||||
}
|
||||
|
||||
karbe.cosmolan.fr {
|
||||
encode zstd gzip
|
||||
reverse_proxy app:3000
|
||||
|
||||
header {
|
||||
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
||||
X-Content-Type-Options "nosniff"
|
||||
Referrer-Policy "strict-origin-when-cross-origin"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
/* config options here */
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
|
|
|||
2628
package-lock.json
generated
2628
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",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
"postinstall": "prisma generate",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"postinstall": "prisma generate"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1056.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1058.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^8.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@prisma/adapter-pg": "^7.8.0",
|
||||
"@prisma/client": "^7.8.0",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"leaflet": "^1.9.4",
|
||||
"next": "16.2.6",
|
||||
"next-auth": "^5.0.0-beta.31",
|
||||
"pg": "^8.21.0",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"resend": "^4.8.0",
|
||||
"sharp": "^0.34.5",
|
||||
"stripe": "^18.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -38,13 +26,11 @@
|
|||
"@types/node": "^20.19.41",
|
||||
"@types/react": "^19.2.15",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"dotenv": "^17.4.2",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-config-next": "^16.2.6",
|
||||
"prisma": "^7.8.0",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^3.2.4"
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,2 +0,0 @@
|
|||
ALTER TABLE "Carbet"
|
||||
ADD COLUMN "lastBookedAt" TIMESTAMP(3);
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
-- Foundation : système Plugin Karbé
|
||||
|
||||
CREATE TABLE "Plugin" (
|
||||
"key" TEXT PRIMARY KEY,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT NOT NULL,
|
||||
"category" TEXT NOT NULL,
|
||||
"version" TEXT NOT NULL DEFAULT '0.1.0',
|
||||
"enabled" BOOLEAN NOT NULL DEFAULT false,
|
||||
"config" JSONB NOT NULL DEFAULT '{}',
|
||||
"migrationsApplied" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
|
||||
"installedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"lastEnabledAt" TIMESTAMP(3),
|
||||
"lastDisabledAt" TIMESTAMP(3)
|
||||
);
|
||||
|
||||
CREATE INDEX "Plugin_category_idx" ON "Plugin" ("category");
|
||||
CREATE INDEX "Plugin_enabled_idx" ON "Plugin" ("enabled");
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
-- Plugin access-type : distinction route+fleuve / fleuve only
|
||||
|
||||
CREATE TYPE "AccessType" AS ENUM ('ROAD_AND_RIVER', 'RIVER_ONLY');
|
||||
|
||||
ALTER TABLE "Carbet"
|
||||
ADD COLUMN "accessType" "AccessType" NOT NULL DEFAULT 'ROAD_AND_RIVER',
|
||||
ADD COLUMN "roadAccessNote" TEXT;
|
||||
|
||||
-- La pirogue n'est obligatoire qu'en RIVER_ONLY. Pour ROAD_AND_RIVER, la valeur
|
||||
-- est optionnelle (estimation pour ceux qui veulent quand même venir en pirogue).
|
||||
ALTER TABLE "Carbet" ALTER COLUMN "pirogueDurationMin" DROP NOT NULL;
|
||||
|
||||
CREATE INDEX "Carbet_accessType_idx" ON "Carbet" ("accessType");
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
-- Plugin seasonality + min-stay : champs sur Carbet
|
||||
|
||||
ALTER TABLE "Carbet"
|
||||
ADD COLUMN "seasonalConstraints" JSONB,
|
||||
ADD COLUMN "minStayNights" INTEGER,
|
||||
ADD COLUMN "maxStayNights" INTEGER,
|
||||
ADD COLUMN "minCapacity" INTEGER;
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
-- Plugin content-pages + legal-pages : table ContentPage
|
||||
|
||||
CREATE TABLE "ContentPage" (
|
||||
"slug" TEXT PRIMARY KEY,
|
||||
"title" TEXT NOT NULL,
|
||||
"body" TEXT NOT NULL,
|
||||
"lang" TEXT NOT NULL DEFAULT 'fr',
|
||||
"category" TEXT NOT NULL DEFAULT 'general',
|
||||
"published" BOOLEAN NOT NULL DEFAULT true,
|
||||
"lastEditedBy" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX "ContentPage_category_idx" ON "ContentPage" ("category");
|
||||
CREATE INDEX "ContentPage_published_idx" ON "ContentPage" ("published");
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
-- Plugin pirogue-providers : modèle PirogueProvider + transportMode sur Carbet
|
||||
|
||||
CREATE TYPE "TransportMode" AS ENUM ('OWNER_PROVIDES', 'SELF_ARRANGE', 'PARTNER_PROVIDER');
|
||||
|
||||
CREATE TABLE "PirogueProvider" (
|
||||
"id" TEXT PRIMARY KEY,
|
||||
"name" TEXT NOT NULL,
|
||||
"contactEmail" TEXT,
|
||||
"contactPhone" TEXT,
|
||||
"rivers" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
|
||||
"pricingNote" TEXT,
|
||||
"description" TEXT,
|
||||
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX "PirogueProvider_active_idx" ON "PirogueProvider" ("active");
|
||||
|
||||
ALTER TABLE "Carbet"
|
||||
ADD COLUMN "transportMode" "TransportMode",
|
||||
ADD COLUMN "pirogueProviderId" TEXT;
|
||||
|
||||
ALTER TABLE "Carbet"
|
||||
ADD CONSTRAINT "Carbet_pirogueProviderId_fkey"
|
||||
FOREIGN KEY ("pirogueProviderId") REFERENCES "PirogueProvider"("id")
|
||||
ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX "Carbet_pirogueProviderId_idx" ON "Carbet" ("pirogueProviderId");
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
-- Plugin i18n-fr-en + content-pages :
|
||||
-- ContentPage devient bilingue → PK composite (slug, lang)
|
||||
-- pour pouvoir stocker une version FR et une version EN du même slug.
|
||||
|
||||
ALTER TABLE "ContentPage" DROP CONSTRAINT "ContentPage_pkey";
|
||||
ALTER TABLE "ContentPage" ADD CONSTRAINT "ContentPage_pkey" PRIMARY KEY ("slug", "lang");
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "ContentPage_slug_idx" ON "ContentPage" ("slug");
|
||||
|
|
@ -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");
|
||||
|
|
@ -13,7 +13,6 @@ enum UserRole {
|
|||
CE_MEMBER
|
||||
TOURIST
|
||||
ADMIN
|
||||
RENTAL_PROVIDER
|
||||
}
|
||||
|
||||
enum CarbetStatus {
|
||||
|
|
@ -60,17 +59,6 @@ enum SubscriptionStatus {
|
|||
CANCELED
|
||||
}
|
||||
|
||||
enum AccessType {
|
||||
ROAD_AND_RIVER
|
||||
RIVER_ONLY
|
||||
}
|
||||
|
||||
enum TransportMode {
|
||||
OWNER_PROVIDES
|
||||
SELF_ARRANGE
|
||||
PARTNER_PROVIDER
|
||||
}
|
||||
|
||||
model Organization {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
|
|
@ -98,13 +86,11 @@ model User {
|
|||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
organization Organization? @relation(fields: [organizationId], references: [id], onDelete: SetNull)
|
||||
carbets Carbet[] @relation("CarbetOwner")
|
||||
bookings Booking[] @relation("BookingTenant")
|
||||
reviews Review[] @relation("ReviewAuthor")
|
||||
subscriptions Subscription[]
|
||||
rentalProviders RentalProvider[]
|
||||
rentalBookings RentalBooking[] @relation("RentalBookingTenant")
|
||||
organization Organization? @relation(fields: [organizationId], references: [id], onDelete: SetNull)
|
||||
carbets Carbet[] @relation("CarbetOwner")
|
||||
bookings Booking[] @relation("BookingTenant")
|
||||
reviews Review[] @relation("ReviewAuthor")
|
||||
subscriptions Subscription[]
|
||||
|
||||
@@index([organizationId])
|
||||
@@index([role])
|
||||
|
|
@ -120,66 +106,23 @@ model Carbet {
|
|||
latitude Decimal @db.Decimal(9, 6)
|
||||
longitude Decimal @db.Decimal(9, 6)
|
||||
embarkPoint String
|
||||
// Pirogue : obligatoire pour RIVER_ONLY, optionnelle pour ROAD_AND_RIVER
|
||||
// (estimation pour ceux qui veulent quand même venir en pirogue).
|
||||
pirogueDurationMin Int?
|
||||
accessType AccessType @default(ROAD_AND_RIVER)
|
||||
// Détails d'accès route pour ROAD_AND_RIVER (GPS, distance, type de piste).
|
||||
roadAccessNote String?
|
||||
pirogueDurationMin 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.
|
||||
minStayNights Int?
|
||||
maxStayNights Int?
|
||||
minCapacity Int?
|
||||
// Contraintes saisonnières (plugin seasonality). JSON libre, schéma type :
|
||||
// { closedInLowWater: bool, closedSeasons: ["WET"|"DRY"|"LOW_WATER"][], note: string }
|
||||
seasonalConstraints Json?
|
||||
// Plugin pirogue-providers : qui organise le transport ?
|
||||
transportMode TransportMode?
|
||||
pirogueProviderId String?
|
||||
status CarbetStatus @default(DRAFT)
|
||||
lastBookedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
owner User @relation("CarbetOwner", fields: [ownerId], references: [id], onDelete: Restrict)
|
||||
pirogueProvider PirogueProvider? @relation(fields: [pirogueProviderId], references: [id], onDelete: SetNull)
|
||||
amenities CarbetAmenity[]
|
||||
media Media[]
|
||||
availabilities Availability[]
|
||||
bookings Booking[]
|
||||
reviews Review[]
|
||||
owner User @relation("CarbetOwner", fields: [ownerId], references: [id], onDelete: Restrict)
|
||||
amenities CarbetAmenity[]
|
||||
media Media[]
|
||||
availabilities Availability[]
|
||||
bookings Booking[]
|
||||
reviews Review[]
|
||||
subscriptions Subscription[]
|
||||
|
||||
@@index([ownerId])
|
||||
@@index([status])
|
||||
@@index([river])
|
||||
@@index([accessType])
|
||||
@@index([pirogueProviderId])
|
||||
}
|
||||
|
||||
model PirogueProvider {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
contactEmail String?
|
||||
contactPhone String?
|
||||
rivers String[] @default([])
|
||||
pricingNote String?
|
||||
description String?
|
||||
active Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
carbets Carbet[]
|
||||
|
||||
@@index([active])
|
||||
}
|
||||
|
||||
model Amenity {
|
||||
|
|
@ -252,8 +195,7 @@ model Booking {
|
|||
carbet Carbet @relation(fields: [carbetId], references: [id], onDelete: Restrict)
|
||||
tenant User @relation("BookingTenant", fields: [tenantId], references: [id], onDelete: Restrict)
|
||||
|
||||
review Review?
|
||||
rentalBookings RentalBooking[]
|
||||
review Review?
|
||||
|
||||
@@index([carbetId])
|
||||
@@index([tenantId])
|
||||
|
|
@ -301,232 +243,3 @@ model Review {
|
|||
@@index([carbetId])
|
||||
@@index([authorId])
|
||||
}
|
||||
|
||||
model Plugin {
|
||||
key String @id
|
||||
name String
|
||||
description String
|
||||
category String
|
||||
version String @default("0.1.0")
|
||||
enabled Boolean @default(false)
|
||||
config Json @default("{}")
|
||||
migrationsApplied String[] @default([])
|
||||
installedAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
lastEnabledAt DateTime?
|
||||
lastDisabledAt DateTime?
|
||||
|
||||
@@index([category])
|
||||
@@index([enabled])
|
||||
}
|
||||
|
||||
model ContentPage {
|
||||
slug String
|
||||
lang String @default("fr")
|
||||
title String
|
||||
body String
|
||||
// 'general' (about, faq, ...) ou 'legal' (cgv, mentions, ...)
|
||||
category String @default("general")
|
||||
published Boolean @default(true)
|
||||
lastEditedBy String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@id([slug, lang])
|
||||
@@index([slug])
|
||||
@@index([category])
|
||||
@@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?
|
||||
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)
|
||||
items RentalItem[]
|
||||
rentalBookings RentalBooking[]
|
||||
|
||||
@@index([active, approved])
|
||||
@@index([managedByUserId])
|
||||
}
|
||||
|
||||
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[]
|
||||
|
||||
@@index([providerId])
|
||||
@@index([category, active])
|
||||
}
|
||||
|
||||
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,35 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# Upload des illustrations aquarelles dans MinIO sous karbe-medias/seed/aquarelle/
|
||||
# + applique policy download (public-read) pour qu'elles soient servies via
|
||||
# media.karbe.cosmolan.fr.
|
||||
#
|
||||
# Prerequis :
|
||||
# - Fichiers présents dans /tmp/karbe-aquarelles/
|
||||
# - MinIO container karbe-minio en up + bucket karbe-medias existant
|
||||
# - .env.production accessible pour récupérer MINIO_ROOT_USER/PASSWORD
|
||||
#
|
||||
# Usage : ./scripts/upload-aquarelles.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SRC="${1:-/tmp/karbe-aquarelles}"
|
||||
BUCKET="karbe-medias"
|
||||
PREFIX="seed/aquarelle"
|
||||
|
||||
ENV_FILE="/home/ubuntu/karbe/.env.production"
|
||||
USER=$(sudo grep -oP '^MINIO_ROOT_USER=\K.*' "$ENV_FILE")
|
||||
PASS=$(sudo grep -oP '^MINIO_ROOT_PASSWORD=\K.*' "$ENV_FILE")
|
||||
|
||||
echo " upload depuis $SRC vers minio://$BUCKET/$PREFIX/"
|
||||
docker run --rm \
|
||||
--network karbe-net \
|
||||
-v "$SRC:/data:ro" \
|
||||
--entrypoint sh \
|
||||
minio/mc:latest \
|
||||
-c "
|
||||
mc alias set karbe http://karbe-minio:9000 '$USER' '$PASS' >/dev/null
|
||||
mc cp /data/*.jpg /data/*.png karbe/$BUCKET/$PREFIX/
|
||||
mc anonymous set download karbe/$BUCKET || true
|
||||
echo '---'
|
||||
mc ls karbe/$BUCKET/$PREFIX/ | head -20
|
||||
"
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
import { notFound } from "next/navigation";
|
||||
import { getContentPage } from "@/lib/content-pages";
|
||||
import { getLocale } from "@/lib/i18n/server";
|
||||
import { isPluginEnabled } from "@/lib/plugins/server";
|
||||
import { ContentPageRenderer } from "@/components/ContentPageRenderer";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const page = await getContentPage("a-propos", await getLocale());
|
||||
return { title: page?.title ?? "À propos" };
|
||||
}
|
||||
|
||||
export default async function AboutPage() {
|
||||
if (!(await isPluginEnabled("content-pages"))) notFound();
|
||||
const page = await getContentPage("a-propos", await getLocale());
|
||||
if (!page) notFound();
|
||||
return <ContentPageRenderer page={page} />;
|
||||
}
|
||||
|
|
@ -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,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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,156 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { BookingStatus, PaymentStatus } from "@/generated/prisma/enums";
|
||||
import {
|
||||
refundBookingAction,
|
||||
updateBookingPaymentAction,
|
||||
updateBookingStatusAction,
|
||||
} from "../../actions";
|
||||
|
||||
type Status = (typeof BookingStatus)[keyof typeof BookingStatus];
|
||||
type Payment = (typeof PaymentStatus)[keyof typeof PaymentStatus];
|
||||
|
||||
const btnBase =
|
||||
"rounded-md px-3 py-1.5 text-xs font-semibold transition disabled:opacity-50";
|
||||
|
||||
export function BookingActions({
|
||||
id,
|
||||
status,
|
||||
paymentStatus,
|
||||
}: {
|
||||
id: string;
|
||||
status: Status;
|
||||
paymentStatus: Payment;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [confirmRefund, setConfirmRefund] = useState(false);
|
||||
|
||||
function setStatus(next: Status) {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const res = await updateBookingStatusAction(id, next);
|
||||
if (res && res.ok === false) setError(res.error);
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
function setPayment(next: Payment) {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const res = await updateBookingPaymentAction(id, next);
|
||||
if (res && res.ok === false) setError(res.error);
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
function refund() {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
await refundBookingAction(id);
|
||||
setConfirmRefund(false);
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-[11px] uppercase tracking-wider text-zinc-500">Statut résa :</span>
|
||||
{status === BookingStatus.PENDING ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() => setStatus(BookingStatus.CONFIRMED)}
|
||||
className={`${btnBase} bg-emerald-600 text-white hover:bg-emerald-700`}
|
||||
>
|
||||
Confirmer
|
||||
</button>
|
||||
) : null}
|
||||
{status === BookingStatus.CONFIRMED ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() => setStatus(BookingStatus.COMPLETED)}
|
||||
className={`${btnBase} border border-zinc-300 bg-white text-zinc-700 hover:bg-zinc-50`}
|
||||
>
|
||||
Marquer terminé
|
||||
</button>
|
||||
) : null}
|
||||
{status !== BookingStatus.CANCELLED && status !== BookingStatus.COMPLETED ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() => setStatus(BookingStatus.CANCELLED)}
|
||||
className={`${btnBase} border border-rose-300 bg-rose-50 text-rose-700 hover:bg-rose-100`}
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-[11px] uppercase tracking-wider text-zinc-500">Paiement :</span>
|
||||
{paymentStatus !== PaymentStatus.SUCCEEDED && paymentStatus !== PaymentStatus.REFUNDED ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() => setPayment(PaymentStatus.SUCCEEDED)}
|
||||
className={`${btnBase} bg-emerald-600 text-white hover:bg-emerald-700`}
|
||||
>
|
||||
Marquer payé
|
||||
</button>
|
||||
) : null}
|
||||
{paymentStatus !== PaymentStatus.FAILED && paymentStatus !== PaymentStatus.REFUNDED ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() => setPayment(PaymentStatus.FAILED)}
|
||||
className={`${btnBase} border border-rose-300 bg-rose-50 text-rose-700 hover:bg-rose-100`}
|
||||
>
|
||||
Marquer échec
|
||||
</button>
|
||||
) : null}
|
||||
{paymentStatus === PaymentStatus.SUCCEEDED ? (
|
||||
confirmRefund ? (
|
||||
<div className="flex items-center gap-2 rounded border border-amber-300 bg-amber-50 px-2 py-1">
|
||||
<span className="text-xs text-amber-900">Rembourser & annuler ?</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={refund}
|
||||
disabled={pending}
|
||||
className="rounded bg-amber-700 px-2 py-1 text-[11px] font-semibold text-white hover:bg-amber-800 disabled:opacity-50"
|
||||
>
|
||||
Oui
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmRefund(false)}
|
||||
disabled={pending}
|
||||
className="text-[11px] text-zinc-500 hover:text-zinc-900"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmRefund(true)}
|
||||
disabled={pending}
|
||||
className={`${btnBase} border border-amber-300 bg-amber-50 text-amber-800 hover:bg-amber-100`}
|
||||
>
|
||||
Rembourser
|
||||
</button>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">{error}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,121 +0,0 @@
|
|||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { getBookingForAdmin } from "@/lib/admin/bookings";
|
||||
import { StatusBadge } from "@/components/admin/StatusBadge";
|
||||
import { BookingActions } from "./_components/BookingActions";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type PageProps = { params: Promise<{ id: string }> };
|
||||
|
||||
export default async function BookingDetailPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
const booking = await getBookingForAdmin(id);
|
||||
if (!booking) notFound();
|
||||
|
||||
const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "long", year: "numeric" });
|
||||
const dateTimeFmt = new Intl.DateTimeFormat("fr-FR", {
|
||||
day: "2-digit", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit",
|
||||
});
|
||||
const nights = Math.max(1, Math.round((booking.endDate.getTime() - booking.startDate.getTime()) / 86400000));
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl space-y-6">
|
||||
<header className="mt-2">
|
||||
<Link href="/admin/bookings" className="text-xs text-zinc-500 hover:text-zinc-900">
|
||||
← Toutes les réservations
|
||||
</Link>
|
||||
<h1 className="mt-1 flex items-center gap-3 text-2xl font-semibold text-zinc-900">
|
||||
Réservation <code className="text-base text-zinc-500">{booking.id.slice(0, 12)}</code>
|
||||
<StatusBadge status={booking.status} />
|
||||
<StatusBadge status={booking.paymentStatus} />
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-zinc-500">
|
||||
Créée le {dateTimeFmt.format(booking.createdAt)} · MAJ {dateTimeFmt.format(booking.updatedAt)}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
||||
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">Actions</h2>
|
||||
<BookingActions id={booking.id} status={booking.status} paymentStatus={booking.paymentStatus} />
|
||||
</section>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<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">Séjour</h2>
|
||||
<dl className="space-y-2 text-sm">
|
||||
<Row label="Du" value={dateFmt.format(booking.startDate)} />
|
||||
<Row label="Au" value={dateFmt.format(booking.endDate)} />
|
||||
<Row label="Durée" value={`${nights} nuit${nights > 1 ? "s" : ""}`} />
|
||||
<Row label="Voyageurs" value={String(booking.guestCount)} />
|
||||
<Row label="Montant" value={`${Number(booking.amount).toFixed(2)} ${booking.currency}`} />
|
||||
</dl>
|
||||
</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">Carbet</h2>
|
||||
<dl className="space-y-2 text-sm">
|
||||
<Row
|
||||
label="Titre"
|
||||
value={
|
||||
<Link href={`/admin/carbets/${booking.carbet.id}`} className="font-medium text-zinc-900 hover:underline">
|
||||
{booking.carbet.title}
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
<Row label="Slug" value={<code>/{booking.carbet.slug}</code>} />
|
||||
<Row label="Fleuve" value={booking.carbet.river} />
|
||||
<Row
|
||||
label="Propriétaire"
|
||||
value={
|
||||
<Link href={`/admin/users/${booking.carbet.owner.id}`} className="text-zinc-900 hover:underline">
|
||||
{booking.carbet.owner.firstName} {booking.carbet.owner.lastName}
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</dl>
|
||||
</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">Locataire</h2>
|
||||
<dl className="space-y-2 text-sm">
|
||||
<Row
|
||||
label="Nom"
|
||||
value={
|
||||
<Link href={`/admin/users/${booking.tenant.id}`} className="text-zinc-900 hover:underline">
|
||||
{booking.tenant.firstName} {booking.tenant.lastName}
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
<Row label="Email" value={booking.tenant.email} />
|
||||
{booking.tenant.phone ? <Row label="Téléphone" value={booking.tenant.phone} /> : null}
|
||||
<Row label="Rôle" value={booking.tenant.role} />
|
||||
</dl>
|
||||
</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">Avis</h2>
|
||||
{booking.review ? (
|
||||
<p className="text-sm text-zinc-700">
|
||||
Note <strong>{booking.review.rating}/5</strong> · déposé le {dateFmt.format(booking.review.createdAt)} ·{" "}
|
||||
<Link href={`/admin/reviews?q=${booking.review.id}`} className="text-zinc-900 hover:underline">
|
||||
Voir l'avis
|
||||
</Link>
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-zinc-500">Pas encore d'avis pour cette réservation.</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-baseline justify-between gap-3 border-b border-zinc-100 pb-1.5 last:border-b-0 last:pb-0">
|
||||
<dt className="text-[11px] uppercase tracking-wider text-zinc-500">{label}</dt>
|
||||
<dd className="text-sm text-zinc-900">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,108 +0,0 @@
|
|||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { auth } from "@/auth";
|
||||
import { BookingStatus, PaymentStatus, UserRole } from "@/generated/prisma/enums";
|
||||
import { requireRole } from "@/lib/authorization";
|
||||
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>) {
|
||||
await recordAudit({ scope: "admin.bookings", event, target, actorEmail: actor, details });
|
||||
}
|
||||
|
||||
const ALLOWED_STATUS = new Set<string>([
|
||||
BookingStatus.PENDING,
|
||||
BookingStatus.CONFIRMED,
|
||||
BookingStatus.CANCELLED,
|
||||
BookingStatus.COMPLETED,
|
||||
]);
|
||||
const ALLOWED_PAYMENT = new Set<string>([
|
||||
PaymentStatus.PENDING,
|
||||
PaymentStatus.AUTHORIZED,
|
||||
PaymentStatus.SUCCEEDED,
|
||||
PaymentStatus.FAILED,
|
||||
PaymentStatus.REFUNDED,
|
||||
]);
|
||||
|
||||
export async function updateBookingStatusAction(id: string, status: string) {
|
||||
await requireRole([UserRole.ADMIN]);
|
||||
if (!ALLOWED_STATUS.has(status)) {
|
||||
return { ok: false as const, error: "Statut invalide" };
|
||||
}
|
||||
const session = await auth();
|
||||
const before = await prisma.booking.findUnique({
|
||||
where: { id },
|
||||
select: { status: true },
|
||||
});
|
||||
const updated = await prisma.booking.update({
|
||||
where: { id },
|
||||
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 });
|
||||
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/${id}`);
|
||||
return { ok: true as const };
|
||||
}
|
||||
|
||||
export async function updateBookingPaymentAction(id: string, paymentStatus: string) {
|
||||
await requireRole([UserRole.ADMIN]);
|
||||
if (!ALLOWED_PAYMENT.has(paymentStatus)) {
|
||||
return { ok: false as const, error: "Statut de paiement invalide" };
|
||||
}
|
||||
const session = await auth();
|
||||
await prisma.booking.update({
|
||||
where: { id },
|
||||
data: { paymentStatus: paymentStatus as PaymentStatus },
|
||||
});
|
||||
await audit("booking.payment.update", id, session?.user?.email ?? null, { paymentStatus });
|
||||
revalidatePath("/admin/bookings");
|
||||
revalidatePath(`/admin/bookings/${id}`);
|
||||
return { ok: true as const };
|
||||
}
|
||||
|
||||
export async function refundBookingAction(id: string) {
|
||||
await requireRole([UserRole.ADMIN]);
|
||||
const session = await auth();
|
||||
const updated = await prisma.booking.update({
|
||||
where: { id },
|
||||
data: {
|
||||
paymentStatus: PaymentStatus.REFUNDED,
|
||||
status: BookingStatus.CANCELLED,
|
||||
},
|
||||
include: {
|
||||
tenant: { select: { email: true, firstName: true } },
|
||||
carbet: { select: { title: true } },
|
||||
},
|
||||
});
|
||||
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/${id}`);
|
||||
return { ok: true as const };
|
||||
}
|
||||
|
|
@ -1,184 +0,0 @@
|
|||
import Link from "next/link";
|
||||
import { BookingStatus, PaymentStatus } from "@/generated/prisma/enums";
|
||||
import { listBookingsAdmin } from "@/lib/admin/bookings";
|
||||
import { StatusBadge } from "@/components/admin/StatusBadge";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type PageProps = {
|
||||
searchParams: Promise<{
|
||||
q?: string;
|
||||
status?: string;
|
||||
paymentStatus?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
const STATUS_VALUES = new Set<string>([
|
||||
BookingStatus.PENDING,
|
||||
BookingStatus.CONFIRMED,
|
||||
BookingStatus.CANCELLED,
|
||||
BookingStatus.COMPLETED,
|
||||
]);
|
||||
const PAYMENT_VALUES = new Set<string>([
|
||||
PaymentStatus.PENDING,
|
||||
PaymentStatus.AUTHORIZED,
|
||||
PaymentStatus.SUCCEEDED,
|
||||
PaymentStatus.FAILED,
|
||||
PaymentStatus.REFUNDED,
|
||||
]);
|
||||
|
||||
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 BookingsAdminPage({ searchParams }: PageProps) {
|
||||
const sp = await searchParams;
|
||||
const filters = {
|
||||
q: sp.q?.trim() || undefined,
|
||||
status: STATUS_VALUES.has(sp.status ?? "") ? (sp.status as BookingStatus) : undefined,
|
||||
paymentStatus: PAYMENT_VALUES.has(sp.paymentStatus ?? "")
|
||||
? (sp.paymentStatus as PaymentStatus)
|
||||
: undefined,
|
||||
from: parseDate(sp.from),
|
||||
to: parseDate(sp.to),
|
||||
};
|
||||
const bookings = await listBookingsAdmin(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 flex items-end justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-zinc-900">Réservations</h1>
|
||||
<p className="mt-1 text-sm text-zinc-500">
|
||||
{bookings.length} résultat{bookings.length > 1 ? "s" : ""}
|
||||
{bookings.length === 200 ? " (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 ID, locataire, carbet…"
|
||||
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>
|
||||
<option value={BookingStatus.PENDING}>En attente</option>
|
||||
<option value={BookingStatus.CONFIRMED}>Confirmé</option>
|
||||
<option value={BookingStatus.CANCELLED}>Annulé</option>
|
||||
<option value={BookingStatus.COMPLETED}>Terminé</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>
|
||||
<option value={PaymentStatus.PENDING}>En attente</option>
|
||||
<option value={PaymentStatus.AUTHORIZED}>Autorisé</option>
|
||||
<option value={PaymentStatus.SUCCEEDED}>Payé</option>
|
||||
<option value={PaymentStatus.FAILED}>Échec</option>
|
||||
<option value={PaymentStatus.REFUNDED}>Remboursé</option>
|
||||
</select>
|
||||
<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.status || filters.paymentStatus || filters.from || filters.to) ? (
|
||||
<Link href="/admin/bookings" 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">Carbet</th>
|
||||
<th className="px-4 py-2 text-left font-semibold">Locataire</th>
|
||||
<th className="px-4 py-2 text-left font-semibold">Séjour</th>
|
||||
<th className="px-4 py-2 text-right font-semibold">Pers.</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>
|
||||
<th className="px-4 py-2 text-right font-semibold">Créé</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-100">
|
||||
{bookings.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={9} className="px-4 py-8 text-center text-sm text-zinc-500">
|
||||
Aucune réservation ne correspond aux filtres.
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{bookings.map((b) => (
|
||||
<tr key={b.id} className="hover:bg-zinc-50">
|
||||
<td className="px-4 py-2">
|
||||
<Link href={`/admin/bookings/${b.id}`} className="font-mono text-[11px] text-zinc-900 hover:underline">
|
||||
{b.id.slice(0, 10)}…
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<Link href={`/admin/carbets/${b.carbet.id}`} className="font-medium text-zinc-900 hover:underline">
|
||||
{b.carbet.title}
|
||||
</Link>
|
||||
<div className="text-[11px] text-zinc-500">
|
||||
<code>/{b.carbet.slug}</code>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-zinc-700">
|
||||
{b.tenant.firstName} {b.tenant.lastName}
|
||||
<div className="text-[11px] text-zinc-500">{b.tenant.email}</div>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-zinc-700">
|
||||
{dateFmt.format(b.startDate)} → {dateFmt.format(b.endDate)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right font-mono text-zinc-700">{b.guestCount}</td>
|
||||
<td className="px-4 py-2 text-right font-mono text-zinc-900">
|
||||
{Number(b.amount).toFixed(2)} {b.currency}
|
||||
</td>
|
||||
<td className="px-4 py-2"><StatusBadge status={b.status} /></td>
|
||||
<td className="px-4 py-2"><StatusBadge status={b.paymentStatus} /></td>
|
||||
<td className="px-4 py-2 text-right text-[11px] text-zinc-500">
|
||||
{dateFmt.format(b.createdAt)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,141 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { addMediaAction, removeMediaAction, reorderMediaAction } from "../../actions";
|
||||
import { FormField, inputCls, selectCls } from "@/components/admin/FormField";
|
||||
|
||||
type MediaItem = {
|
||||
id: string;
|
||||
type: "PHOTO" | "VIDEO";
|
||||
s3Key: string;
|
||||
s3Url: string;
|
||||
sortOrder: number;
|
||||
};
|
||||
|
||||
export function MediaManager({ carbetId, media: initial }: { carbetId: string; media: MediaItem[] }) {
|
||||
const [media, setMedia] = useState(initial);
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function refresh() {
|
||||
const r = await fetch(`/api/admin/carbets/${carbetId}/media`);
|
||||
if (r.ok) setMedia(await r.json());
|
||||
}
|
||||
|
||||
function addByUrl(fd: FormData) {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const res = await addMediaAction(carbetId, fd);
|
||||
if (res?.ok === false) {
|
||||
setError(res.error);
|
||||
} else {
|
||||
await refresh();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function remove(mediaId: string) {
|
||||
startTransition(async () => {
|
||||
await removeMediaAction(carbetId, mediaId);
|
||||
await refresh();
|
||||
});
|
||||
}
|
||||
|
||||
function reorder(mediaId: string, dir: "up" | "down") {
|
||||
startTransition(async () => {
|
||||
await reorderMediaAction(carbetId, mediaId, dir);
|
||||
await refresh();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div 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">Médias ({media.length})</h2>
|
||||
|
||||
{media.length === 0 ? (
|
||||
<p className="mb-4 rounded border border-dashed border-zinc-300 bg-zinc-50 p-4 text-sm text-zinc-500">
|
||||
Aucun média. Ajoute une URL ci-dessous (MinIO, CDN externe, …).
|
||||
</p>
|
||||
) : (
|
||||
<ul className="mb-4 divide-y divide-zinc-100 rounded border border-zinc-200">
|
||||
{media.map((m, i) => (
|
||||
<li key={m.id} className="flex items-center gap-3 px-3 py-2">
|
||||
<span className="font-mono text-xs text-zinc-500">#{i + 1}</span>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={m.s3Url}
|
||||
alt=""
|
||||
className="h-12 w-16 rounded object-cover ring-1 ring-zinc-200"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-xs text-zinc-700">{m.s3Url}</div>
|
||||
<div className="text-[11px] text-zinc-500">
|
||||
{m.type} · <code>{m.s3Key}</code>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => reorder(m.id, "up")}
|
||||
disabled={pending || i === 0}
|
||||
className="rounded border border-zinc-200 px-2 py-1 text-xs hover:bg-zinc-50 disabled:opacity-30"
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => reorder(m.id, "down")}
|
||||
disabled={pending || i === media.length - 1}
|
||||
className="rounded border border-zinc-200 px-2 py-1 text-xs hover:bg-zinc-50 disabled:opacity-30"
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(m.id)}
|
||||
disabled={pending}
|
||||
className="rounded border border-rose-200 bg-rose-50 px-2 py-1 text-xs text-rose-700 hover:bg-rose-100 disabled:opacity-50"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<form action={addByUrl} className="space-y-3 rounded border border-zinc-200 bg-zinc-50 p-3">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-zinc-500">Ajouter un média par URL</h3>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-4">
|
||||
<FormField label="URL" className="sm:col-span-3">
|
||||
<input
|
||||
name="url"
|
||||
type="url"
|
||||
required
|
||||
className={inputCls}
|
||||
placeholder="https://media.karbe.cosmolan.fr/…"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Type">
|
||||
<select name="type" defaultValue="PHOTO" className={selectCls}>
|
||||
<option value="PHOTO">Photo</option>
|
||||
<option value="VIDEO">Vidéo</option>
|
||||
</select>
|
||||
</FormField>
|
||||
</div>
|
||||
{/* Le serveur calcule un s3Key déterministe à partir de l'URL si vide. */}
|
||||
{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">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={pending}
|
||||
className="rounded-md bg-zinc-900 px-3 py-1.5 text-xs font-semibold text-white hover:bg-zinc-800 disabled:opacity-50"
|
||||
>
|
||||
{pending ? "Ajout…" : "Ajouter"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,93 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { CarbetStatus } from "@/generated/prisma/enums";
|
||||
import { deleteCarbetAction, updateCarbetStatusAction } from "../../actions";
|
||||
|
||||
type Status = (typeof CarbetStatus)[keyof typeof CarbetStatus];
|
||||
|
||||
export function StatusActions({ id, current }: { id: string; current: Status }) {
|
||||
const router = useRouter();
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [confirmArchive, setConfirmArchive] = useState(false);
|
||||
|
||||
function setStatus(next: Status) {
|
||||
startTransition(async () => {
|
||||
await updateCarbetStatusAction(id, next);
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
function archive() {
|
||||
startTransition(async () => {
|
||||
await deleteCarbetAction(id);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{current === CarbetStatus.DRAFT ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStatus(CarbetStatus.PUBLISHED)}
|
||||
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"
|
||||
>
|
||||
Publier
|
||||
</button>
|
||||
) : null}
|
||||
{current === CarbetStatus.PUBLISHED ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStatus(CarbetStatus.DRAFT)}
|
||||
disabled={pending}
|
||||
className="rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-xs font-semibold text-zinc-700 hover:bg-zinc-50 disabled:opacity-50"
|
||||
>
|
||||
Dépublier (brouillon)
|
||||
</button>
|
||||
) : null}
|
||||
{current !== CarbetStatus.ARCHIVED ? (
|
||||
confirmArchive ? (
|
||||
<div className="flex items-center gap-2 rounded border border-amber-300 bg-amber-50 px-2 py-1">
|
||||
<span className="text-xs text-amber-900">Sûr ?</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={archive}
|
||||
disabled={pending}
|
||||
className="rounded bg-amber-700 px-2 py-1 text-[11px] font-semibold text-white hover:bg-amber-800 disabled:opacity-50"
|
||||
>
|
||||
Oui, archiver
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmArchive(false)}
|
||||
disabled={pending}
|
||||
className="text-[11px] text-zinc-500 hover:text-zinc-900"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmArchive(true)}
|
||||
disabled={pending}
|
||||
className="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"
|
||||
>
|
||||
Archiver
|
||||
</button>
|
||||
)
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStatus(CarbetStatus.DRAFT)}
|
||||
disabled={pending}
|
||||
className="rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-xs font-semibold text-zinc-700 hover:bg-zinc-50 disabled:opacity-50"
|
||||
>
|
||||
Réactiver (brouillon)
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,113 +0,0 @@
|
|||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
getCarbetForEdit,
|
||||
listOwners,
|
||||
listPirogueProviders,
|
||||
} from "@/lib/admin/carbets";
|
||||
import { CarbetForm } from "../_components/CarbetForm";
|
||||
import { StatusBadge } from "@/components/admin/StatusBadge";
|
||||
import { MediaUploader } from "@/components/MediaUploader";
|
||||
import { StatusActions } from "./_components/StatusActions";
|
||||
import { updateCarbetAction } from "../actions";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type PageProps = { params: Promise<{ id: string }> };
|
||||
|
||||
export default async function EditCarbetPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
const [carbet, owners, providers] = await Promise.all([
|
||||
getCarbetForEdit(id),
|
||||
listOwners(),
|
||||
listPirogueProviders(),
|
||||
]);
|
||||
if (!carbet) notFound();
|
||||
|
||||
const updateThis = async (fd: FormData) => {
|
||||
"use server";
|
||||
return await updateCarbetAction(id, fd);
|
||||
};
|
||||
|
||||
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/carbets" className="text-xs text-zinc-500 hover:text-zinc-900">
|
||||
← Tous les carbets
|
||||
</Link>
|
||||
<h1 className="mt-1 flex items-center gap-3 text-2xl font-semibold text-zinc-900">
|
||||
{carbet.title}
|
||||
<StatusBadge status={carbet.status} />
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-zinc-500">
|
||||
<code>/{carbet.slug}</code> · {carbet._count.bookings} résa
|
||||
{carbet._count.bookings > 1 ? "s" : ""} · {carbet._count.reviews} avis ·
|
||||
mis à jour {new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "long", year: "numeric" }).format(carbet.updatedAt)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<StatusActions id={carbet.id} current={carbet.status} />
|
||||
{carbet.status === "PUBLISHED" ? (
|
||||
<a
|
||||
href={`/carbets/${carbet.slug}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-xs text-zinc-500 hover:text-zinc-900"
|
||||
>
|
||||
↗ Voir la fiche publique
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="mb-6 rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
||||
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">
|
||||
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
|
||||
owners={owners}
|
||||
providers={providers}
|
||||
action={updateThis}
|
||||
submitLabel="Enregistrer les modifications"
|
||||
initial={{
|
||||
ownerId: carbet.owner.id,
|
||||
title: carbet.title,
|
||||
slug: carbet.slug,
|
||||
description: carbet.description,
|
||||
river: carbet.river,
|
||||
embarkPoint: carbet.embarkPoint,
|
||||
latitude: carbet.latitude.toString(),
|
||||
longitude: carbet.longitude.toString(),
|
||||
capacity: carbet.capacity,
|
||||
nightlyPrice: carbet.nightlyPrice.toString(),
|
||||
accessType: carbet.accessType,
|
||||
roadAccess: carbet.roadAccess,
|
||||
electricity: carbet.electricity,
|
||||
gsmAtCarbet: carbet.gsmAtCarbet,
|
||||
gsmExitDistanceKm: carbet.gsmExitDistanceKm !== null ? carbet.gsmExitDistanceKm.toString() : null,
|
||||
roadAccessNote: carbet.roadAccessNote,
|
||||
pirogueDurationMin: carbet.pirogueDurationMin,
|
||||
minStayNights: carbet.minStayNights,
|
||||
maxStayNights: carbet.maxStayNights,
|
||||
minCapacity: carbet.minCapacity,
|
||||
transportMode: carbet.transportMode,
|
||||
pirogueProviderId: carbet.pirogueProvider?.id ?? null,
|
||||
status: carbet.status,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,342 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { FormField, inputCls, selectCls, textareaCls } from "@/components/admin/FormField";
|
||||
import {
|
||||
ACCESS_TYPE_OPTIONS,
|
||||
STATUS_OPTIONS,
|
||||
TRANSPORT_MODE_OPTIONS,
|
||||
} from "@/lib/admin/carbet-options";
|
||||
|
||||
export type CarbetFormInitial = {
|
||||
ownerId?: string;
|
||||
title?: string;
|
||||
slug?: string;
|
||||
description?: string;
|
||||
river?: string;
|
||||
embarkPoint?: string;
|
||||
latitude?: number | string;
|
||||
longitude?: number | string;
|
||||
capacity?: number;
|
||||
nightlyPrice?: number | string;
|
||||
accessType?: string;
|
||||
roadAccess?: string | null;
|
||||
electricity?: string | null;
|
||||
gsmAtCarbet?: boolean;
|
||||
gsmExitDistanceKm?: number | string | null;
|
||||
roadAccessNote?: string | null;
|
||||
pirogueDurationMin?: number | null;
|
||||
minStayNights?: number | null;
|
||||
maxStayNights?: number | null;
|
||||
minCapacity?: number | null;
|
||||
transportMode?: string | null;
|
||||
pirogueProviderId?: string | null;
|
||||
status?: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
initial?: CarbetFormInitial;
|
||||
owners: { id: string; firstName: string; lastName: string; email: string }[];
|
||||
providers: { id: string; name: string; rivers: string[] }[];
|
||||
action: (fd: FormData) => Promise<{ ok: false; error: string } | { ok: true } | undefined>;
|
||||
submitLabel?: string;
|
||||
};
|
||||
|
||||
export function CarbetForm({ initial = {}, owners, providers, 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("Carbet enregistré.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={onSubmit} className="space-y-6">
|
||||
<fieldset disabled={pending} className="space-y-6">
|
||||
{/* Identité */}
|
||||
<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>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<FormField label="Titre" required>
|
||||
<input name="title" defaultValue={initial.title ?? ""} className={inputCls} required maxLength={200} />
|
||||
</FormField>
|
||||
<FormField label="Slug" required hint="URL publique : /carbets/<slug>">
|
||||
<input
|
||||
name="slug"
|
||||
defaultValue={initial.slug ?? ""}
|
||||
className={inputCls}
|
||||
required
|
||||
pattern="^[a-z0-9](?:[a-z0-9-]{0,80}[a-z0-9])?$"
|
||||
placeholder="ex. karbe-awara-maroni"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Propriétaire" required className="sm:col-span-2">
|
||||
<select name="ownerId" defaultValue={initial.ownerId ?? ""} className={selectCls} required>
|
||||
<option value="" disabled>— sélectionner un propriétaire —</option>
|
||||
{owners.map((o) => (
|
||||
<option key={o.id} value={o.id}>
|
||||
{o.firstName} {o.lastName} ({o.email})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Description" required className="sm:col-span-2" hint="Markdown léger autorisé.">
|
||||
<textarea
|
||||
name="description"
|
||||
rows={6}
|
||||
defaultValue={initial.description ?? ""}
|
||||
className={textareaCls}
|
||||
required
|
||||
maxLength={20000}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Localisation */}
|
||||
<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">Localisation</h2>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<FormField label="Fleuve" required>
|
||||
<input name="river" defaultValue={initial.river ?? ""} className={inputCls} required maxLength={100} placeholder="Maroni" />
|
||||
</FormField>
|
||||
<FormField label="Point d'embarquement" required>
|
||||
<input
|
||||
name="embarkPoint"
|
||||
defaultValue={initial.embarkPoint ?? ""}
|
||||
className={inputCls}
|
||||
required
|
||||
maxLength={200}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Latitude" required hint="Décimal (-90 à 90)">
|
||||
<input
|
||||
name="latitude"
|
||||
type="number"
|
||||
step="0.000001"
|
||||
defaultValue={initial.latitude?.toString() ?? ""}
|
||||
className={inputCls}
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Longitude" required hint="Décimal (-180 à 180)">
|
||||
<input
|
||||
name="longitude"
|
||||
type="number"
|
||||
step="0.000001"
|
||||
defaultValue={initial.longitude?.toString() ?? ""}
|
||||
className={inputCls}
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Accès */}
|
||||
<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">Accès & transport</h2>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<FormField label="Type d'accès" required>
|
||||
<select name="accessType" defaultValue={initial.accessType ?? "ROAD_AND_RIVER"} className={selectCls} required>
|
||||
{ACCESS_TYPE_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Durée pirogue (min)" hint="Optionnel — vide si accès route uniquement">
|
||||
<input
|
||||
name="pirogueDurationMin"
|
||||
type="number"
|
||||
min={0}
|
||||
max={1440}
|
||||
defaultValue={initial.pirogueDurationMin?.toString() ?? ""}
|
||||
className={inputCls}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Note d'accès route" className="sm:col-span-2" hint="GPS, type de piste, distance dernière ville…">
|
||||
<textarea
|
||||
name="roadAccessNote"
|
||||
rows={2}
|
||||
defaultValue={initial.roadAccessNote ?? ""}
|
||||
className={textareaCls}
|
||||
maxLength={1000}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Mode de transport pirogue">
|
||||
<select name="transportMode" defaultValue={initial.transportMode ?? ""} className={selectCls}>
|
||||
<option value="">— non spécifié —</option>
|
||||
{TRANSPORT_MODE_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Prestataire pirogue partenaire">
|
||||
<select name="pirogueProviderId" defaultValue={initial.pirogueProviderId ?? ""} className={selectCls}>
|
||||
<option value="">— aucun —</option>
|
||||
{providers.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name} ({p.rivers.join(", ")})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Critères opérationnels */}
|
||||
<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">
|
||||
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">
|
||||
<FormField label="Capacité" required hint="Voyageurs max">
|
||||
<input
|
||||
name="capacity"
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
defaultValue={initial.capacity?.toString() ?? ""}
|
||||
className={inputCls}
|
||||
required
|
||||
/>
|
||||
</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">
|
||||
<input
|
||||
name="minCapacity"
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
defaultValue={initial.minCapacity?.toString() ?? ""}
|
||||
className={inputCls}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Nuits min" hint="Facultatif">
|
||||
<input
|
||||
name="minStayNights"
|
||||
type="number"
|
||||
min={1}
|
||||
max={365}
|
||||
defaultValue={initial.minStayNights?.toString() ?? ""}
|
||||
className={inputCls}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Nuits max" hint="Facultatif">
|
||||
<input
|
||||
name="maxStayNights"
|
||||
type="number"
|
||||
min={1}
|
||||
max={365}
|
||||
defaultValue={initial.maxStayNights?.toString() ?? ""}
|
||||
className={inputCls}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Publication */}
|
||||
<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">Publication</h2>
|
||||
<FormField label="Statut" hint="Brouillon n'apparaît pas sur le site public. Archivé reste en base mais non listé.">
|
||||
<select name="status" defaultValue={initial.status ?? "DRAFT"} className={selectCls}>
|
||||
{STATUS_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
</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="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,229 +0,0 @@
|
|||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/auth";
|
||||
import { requireRole } from "@/lib/authorization";
|
||||
import { recordAudit } from "@/lib/admin/audit";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import {
|
||||
AccessType,
|
||||
CarbetStatus,
|
||||
Electricity,
|
||||
MediaType,
|
||||
RoadAccess,
|
||||
TransportMode,
|
||||
UserRole,
|
||||
} from "@/generated/prisma/enums";
|
||||
|
||||
const slugRe = /^[a-z0-9](?:[a-z0-9-]{0,80}[a-z0-9])?$/;
|
||||
|
||||
const baseCarbetSchema = z.object({
|
||||
ownerId: z.string().min(1, "Propriétaire requis"),
|
||||
title: z.string().trim().min(1).max(200),
|
||||
slug: z.string().trim().regex(slugRe, "Slug invalide (a-z, 0-9, -)"),
|
||||
description: z.string().trim().min(10).max(20000),
|
||||
river: z.string().trim().min(2).max(100),
|
||||
embarkPoint: z.string().trim().min(2).max(200),
|
||||
latitude: z.coerce.number().min(-90).max(90),
|
||||
longitude: z.coerce.number().min(-180).max(180),
|
||||
capacity: z.coerce.number().int().min(1).max(100),
|
||||
nightlyPrice: z.coerce.number().min(0).max(100000),
|
||||
accessType: z.enum([AccessType.ROAD_AND_RIVER, AccessType.RIVER_ONLY]),
|
||||
roadAccess: z
|
||||
.enum([RoadAccess.NONE, RoadAccess.DRY_SEASON_ONLY, RoadAccess.ALL_YEAR])
|
||||
.optional()
|
||||
.nullable(),
|
||||
electricity: z
|
||||
.enum([Electricity.NONE, Electricity.SOLAR, Electricity.GENERATOR_READY, Electricity.EDF])
|
||||
.optional()
|
||||
.nullable(),
|
||||
gsmAtCarbet: z.preprocess((v) => v === "yes" || v === true, z.boolean()),
|
||||
gsmExitDistanceKm: z.coerce.number().min(0).max(50).optional().nullable(),
|
||||
roadAccessNote: z.string().trim().max(1000).optional().nullable(),
|
||||
pirogueDurationMin: z.coerce.number().int().min(0).max(1440).optional().nullable(),
|
||||
minStayNights: z.coerce.number().int().min(1).max(365).optional().nullable(),
|
||||
maxStayNights: z.coerce.number().int().min(1).max(365).optional().nullable(),
|
||||
minCapacity: z.coerce.number().int().min(1).max(100).optional().nullable(),
|
||||
transportMode: z
|
||||
.enum([TransportMode.OWNER_PROVIDES, TransportMode.SELF_ARRANGE, TransportMode.PARTNER_PROVIDER])
|
||||
.optional()
|
||||
.nullable(),
|
||||
pirogueProviderId: z.string().optional().nullable(),
|
||||
status: z.enum([CarbetStatus.DRAFT, CarbetStatus.PUBLISHED, CarbetStatus.ARCHIVED]).default(CarbetStatus.DRAFT),
|
||||
});
|
||||
|
||||
function normalizeNullable<T>(v: T | "" | undefined | null): T | null {
|
||||
if (v === undefined || v === null || v === "") return null;
|
||||
return v;
|
||||
}
|
||||
|
||||
function parseFromFormData(fd: FormData) {
|
||||
const obj: Record<string, unknown> = {};
|
||||
for (const [k, v] of fd.entries()) {
|
||||
if (typeof v === "string") obj[k] = v;
|
||||
}
|
||||
// Normalise les champs optionnels nullables
|
||||
["roadAccessNote", "pirogueDurationMin", "minStayNights", "maxStayNights", "minCapacity", "transportMode", "pirogueProviderId", "roadAccess", "electricity", "gsmExitDistanceKm"].forEach(
|
||||
(k) => (obj[k] = normalizeNullable(obj[k] as string | null | undefined)),
|
||||
);
|
||||
// gsmAtCarbet : si pas posté, on garde la valeur (sera traité par preprocess Zod)
|
||||
if (!("gsmAtCarbet" in obj)) obj.gsmAtCarbet = "no";
|
||||
return obj;
|
||||
}
|
||||
|
||||
export async function createCarbetAction(fd: FormData) {
|
||||
await requireRole([UserRole.ADMIN]);
|
||||
const parsed = baseCarbetSchema.safeParse(parseFromFormData(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.carbet.create({
|
||||
data: {
|
||||
...parsed.data,
|
||||
lastBookedAt: null,
|
||||
},
|
||||
});
|
||||
await audit("carbet.create", created.id, session?.user?.email ?? null, {
|
||||
slug: created.slug,
|
||||
status: created.status,
|
||||
});
|
||||
revalidatePath("/admin/carbets");
|
||||
redirect(`/admin/carbets/${created.id}`);
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message.includes("Unique constraint")) {
|
||||
return { ok: false as const, error: "Slug déjà utilisé" };
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateCarbetAction(id: string, fd: FormData) {
|
||||
await requireRole([UserRole.ADMIN]);
|
||||
const parsed = baseCarbetSchema.safeParse(parseFromFormData(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 updated = await prisma.carbet.update({
|
||||
where: { id },
|
||||
data: parsed.data,
|
||||
});
|
||||
await audit("carbet.update", updated.id, session?.user?.email ?? null, {
|
||||
slug: updated.slug,
|
||||
status: updated.status,
|
||||
});
|
||||
revalidatePath("/admin/carbets");
|
||||
revalidatePath(`/admin/carbets/${id}`);
|
||||
revalidatePath(`/carbets/${updated.slug}`);
|
||||
return { ok: true as const };
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message.includes("Unique constraint")) {
|
||||
return { ok: false as const, error: "Slug déjà utilisé" };
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateCarbetStatusAction(id: string, status: CarbetStatus) {
|
||||
await requireRole([UserRole.ADMIN]);
|
||||
const session = await auth();
|
||||
await prisma.carbet.update({ where: { id }, data: { status } });
|
||||
await audit("carbet.status", id, session?.user?.email ?? null, { status });
|
||||
revalidatePath("/admin/carbets");
|
||||
revalidatePath(`/admin/carbets/${id}`);
|
||||
return { ok: true as const };
|
||||
}
|
||||
|
||||
export async function deleteCarbetAction(id: string) {
|
||||
await requireRole([UserRole.ADMIN]);
|
||||
const session = await auth();
|
||||
// Soft : on archive plutôt que supprimer (bookings/reviews FK Restrict).
|
||||
const archived = await prisma.carbet.update({
|
||||
where: { id },
|
||||
data: { status: CarbetStatus.ARCHIVED },
|
||||
});
|
||||
await audit("carbet.archive", id, session?.user?.email ?? null, { slug: archived.slug });
|
||||
revalidatePath("/admin/carbets");
|
||||
redirect("/admin/carbets");
|
||||
}
|
||||
|
||||
const mediaSchema = z.object({
|
||||
url: z.string().url().max(2000),
|
||||
type: z.enum([MediaType.PHOTO, MediaType.VIDEO]).default(MediaType.PHOTO),
|
||||
s3Key: z.string().max(500).optional(),
|
||||
});
|
||||
|
||||
export async function addMediaAction(carbetId: string, fd: FormData) {
|
||||
await requireRole([UserRole.ADMIN]);
|
||||
const parsed = mediaSchema.safeParse({
|
||||
url: fd.get("url"),
|
||||
type: fd.get("type") ?? "PHOTO",
|
||||
s3Key: fd.get("s3Key") ?? undefined,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
return { ok: false as const, error: parsed.error.issues.map((i) => i.message).join(" · ") };
|
||||
}
|
||||
const existing = await prisma.media.count({ where: { carbetId } });
|
||||
const session = await auth();
|
||||
const m = await prisma.media.create({
|
||||
data: {
|
||||
carbetId,
|
||||
type: parsed.data.type,
|
||||
s3Url: parsed.data.url,
|
||||
s3Key: parsed.data.s3Key ?? `external/${Date.now()}`,
|
||||
sortOrder: existing,
|
||||
},
|
||||
});
|
||||
await audit("media.create", m.id, session?.user?.email ?? null, { carbetId, url: parsed.data.url });
|
||||
revalidatePath(`/admin/carbets/${carbetId}`);
|
||||
return { ok: true as const };
|
||||
}
|
||||
|
||||
export async function removeMediaAction(carbetId: string, mediaId: string) {
|
||||
await requireRole([UserRole.ADMIN]);
|
||||
const session = await auth();
|
||||
await prisma.media.delete({ where: { id: mediaId } });
|
||||
await audit("media.delete", mediaId, session?.user?.email ?? null, { carbetId });
|
||||
revalidatePath(`/admin/carbets/${carbetId}`);
|
||||
return { ok: true as const };
|
||||
}
|
||||
|
||||
export async function reorderMediaAction(carbetId: string, mediaId: string, direction: "up" | "down") {
|
||||
await requireRole([UserRole.ADMIN]);
|
||||
const all = await prisma.media.findMany({
|
||||
where: { carbetId },
|
||||
orderBy: { sortOrder: "asc" },
|
||||
});
|
||||
const idx = all.findIndex((m) => m.id === mediaId);
|
||||
if (idx === -1) return { ok: false as const };
|
||||
const swap = direction === "up" ? idx - 1 : idx + 1;
|
||||
if (swap < 0 || swap >= all.length) return { ok: false as const };
|
||||
const a = all[idx];
|
||||
const b = all[swap];
|
||||
await prisma.$transaction([
|
||||
prisma.media.update({ where: { id: a.id }, data: { sortOrder: b.sortOrder } }),
|
||||
prisma.media.update({ where: { id: b.id }, data: { sortOrder: a.sortOrder } }),
|
||||
]);
|
||||
revalidatePath(`/admin/carbets/${carbetId}`);
|
||||
return { ok: true as const };
|
||||
}
|
||||
|
||||
async function audit(
|
||||
event: string,
|
||||
entityId: string,
|
||||
actor: string | null,
|
||||
payload: Record<string, unknown>,
|
||||
) {
|
||||
await recordAudit({
|
||||
scope: "admin.carbets",
|
||||
event,
|
||||
target: entityId,
|
||||
actorEmail: actor,
|
||||
details: payload,
|
||||
});
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
import { listOwners, listPirogueProviders } from "@/lib/admin/carbets";
|
||||
import { CarbetForm } from "../_components/CarbetForm";
|
||||
import { createCarbetAction } from "../actions";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function NewCarbetPage() {
|
||||
const [owners, providers] = await Promise.all([listOwners(), listPirogueProviders()]);
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<header className="mb-5 mt-2">
|
||||
<h1 className="text-2xl font-semibold text-zinc-900">Nouveau carbet</h1>
|
||||
<p className="mt-1 text-sm text-zinc-500">
|
||||
Crée un brouillon. Tu pourras le publier ensuite depuis sa fiche.
|
||||
</p>
|
||||
</header>
|
||||
<CarbetForm owners={owners} providers={providers} action={createCarbetAction} submitLabel="Créer le carbet" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,148 +0,0 @@
|
|||
import Link from "next/link";
|
||||
import { AccessType, CarbetStatus } from "@/generated/prisma/enums";
|
||||
import { listCarbetsAdmin, listDistinctRivers } from "@/lib/admin/carbets";
|
||||
import { StatusBadge } from "@/components/admin/StatusBadge";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type PageProps = {
|
||||
searchParams: Promise<{
|
||||
q?: string;
|
||||
river?: string;
|
||||
status?: string;
|
||||
accessType?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
const STATUS_VALUES = new Set<string>([CarbetStatus.DRAFT, CarbetStatus.PUBLISHED, CarbetStatus.ARCHIVED]);
|
||||
const ACCESS_VALUES = new Set<string>([AccessType.ROAD_AND_RIVER, AccessType.RIVER_ONLY]);
|
||||
|
||||
export default async function CarbetsAdminPage({ searchParams }: PageProps) {
|
||||
const sp = await searchParams;
|
||||
const filters = {
|
||||
q: sp.q?.trim() || undefined,
|
||||
river: sp.river || undefined,
|
||||
status: STATUS_VALUES.has(sp.status ?? "") ? (sp.status as CarbetStatus) : undefined,
|
||||
accessType: ACCESS_VALUES.has(sp.accessType ?? "") ? (sp.accessType as AccessType) : undefined,
|
||||
};
|
||||
const [carbets, rivers] = await Promise.all([listCarbetsAdmin(filters), listDistinctRivers()]);
|
||||
|
||||
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">Carbets</h1>
|
||||
<p className="mt-1 text-sm text-zinc-500">
|
||||
{carbets.length} résultat{carbets.length > 1 ? "s" : ""} · brouillons, publiés et archivés
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/admin/carbets/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 carbet
|
||||
</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 par titre, slug, fleuve…"
|
||||
className="flex-1 min-w-[180px] 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 les fleuves</option>
|
||||
{rivers.map((r) => (
|
||||
<option key={r} value={r}>{r}</option>
|
||||
))}
|
||||
</select>
|
||||
<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>
|
||||
<option value={CarbetStatus.DRAFT}>Brouillon</option>
|
||||
<option value={CarbetStatus.PUBLISHED}>Publié</option>
|
||||
<option value={CarbetStatus.ARCHIVED}>Archivé</option>
|
||||
</select>
|
||||
<select
|
||||
name="accessType"
|
||||
defaultValue={filters.accessType ?? ""}
|
||||
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 accès</option>
|
||||
<option value={AccessType.ROAD_AND_RIVER}>🛣️ Route + fleuve</option>
|
||||
<option value={AccessType.RIVER_ONLY}>🛶 Expédition</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.status || filters.accessType) ? (
|
||||
<Link href="/admin/carbets" 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">Titre</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-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">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">Statut</th>
|
||||
<th className="px-4 py-2 text-right font-semibold">MAJ</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-100">
|
||||
{carbets.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={10} className="px-4 py-8 text-center text-sm text-zinc-500">
|
||||
Aucun carbet ne correspond aux filtres.
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{carbets.map((c) => (
|
||||
<tr key={c.id} className="hover:bg-zinc-50">
|
||||
<td className="px-4 py-2">
|
||||
<Link href={`/admin/carbets/${c.id}`} className="font-medium text-zinc-900 hover:underline">
|
||||
{c.title}
|
||||
</Link>
|
||||
<div className="text-[11px] text-zinc-500">
|
||||
<code>/{c.slug}</code>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-zinc-700">{c.river}</td>
|
||||
<td className="px-4 py-2 text-zinc-700">
|
||||
{c.accessType === AccessType.RIVER_ONLY ? "🛶 Fleuve" : "🛣️ Route+fleuve"}
|
||||
</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.bookingsCount}</td>
|
||||
<td className="px-4 py-2 text-zinc-700">{c.ownerName}</td>
|
||||
<td className="px-4 py-2"><StatusBadge status={c.status} /></td>
|
||||
<td className="px-4 py-2 text-right text-[11px] text-zinc-500">
|
||||
{new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short" }).format(c.updatedAt)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
type Page = {
|
||||
slug: string;
|
||||
lang: string;
|
||||
title: string;
|
||||
body: string;
|
||||
category: string;
|
||||
published: boolean;
|
||||
};
|
||||
|
||||
export default function EditorForm({ page }: { page: Page }) {
|
||||
const router = useRouter();
|
||||
const [title, setTitle] = useState(page.title);
|
||||
const [body, setBody] = useState(page.body);
|
||||
const [published, setPublished] = useState(page.published);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
async function save() {
|
||||
setBusy(true);
|
||||
setMsg(null);
|
||||
setErr(null);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/admin/content-pages/${encodeURIComponent(page.slug)}?lang=${encodeURIComponent(page.lang)}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title, body, published }),
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
const j = await res.json().catch(() => ({}));
|
||||
throw new Error(j?.error || `HTTP ${res.status}`);
|
||||
}
|
||||
setMsg("Sauvegardé.");
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<label className="block">
|
||||
<span className="text-sm font-medium text-gray-700">Titre</span>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
Contenu (markdown léger : # ## ### gras italique [link](url) listes - 1. ---)
|
||||
</span>
|
||||
<textarea
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
rows={24}
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 font-mono text-sm leading-relaxed"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="inline-flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={published}
|
||||
onChange={(e) => setPublished(e.target.checked)}
|
||||
/>
|
||||
Publié
|
||||
</label>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={save}
|
||||
disabled={busy}
|
||||
className="rounded-full bg-gray-900 px-5 py-2 text-sm font-semibold text-white hover:bg-gray-800 disabled:opacity-50"
|
||||
>
|
||||
{busy ? "Sauvegarde…" : "Sauvegarder"}
|
||||
</button>
|
||||
{msg ? <span className="text-sm text-green-700">{msg}</span> : null}
|
||||
{err ? <span className="text-sm text-red-700">{err}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,94 +0,0 @@
|
|||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { requireRole } from "@/lib/authorization";
|
||||
import { UserRole } from "@/generated/prisma/enums";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import EditorForm from "./_components/EditorForm";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{ slug: string }>;
|
||||
searchParams: Promise<{ lang?: string }>;
|
||||
};
|
||||
|
||||
function normalizeLang(v: string | undefined): string {
|
||||
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]);
|
||||
const { slug } = await params;
|
||||
const sp = await searchParams;
|
||||
const lang = normalizeLang(sp.lang);
|
||||
|
||||
const [row, siblings] = await Promise.all([
|
||||
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();
|
||||
|
||||
const page = {
|
||||
slug: row.slug,
|
||||
lang: row.lang,
|
||||
title: row.title,
|
||||
body: row.body,
|
||||
category: row.category,
|
||||
published: row.published,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<header className="mt-2">
|
||||
<Link href="/admin/content-pages" className="text-xs text-zinc-500 hover:text-zinc-900">
|
||||
← Toutes les pages
|
||||
</Link>
|
||||
<h1 className="mt-1 flex flex-wrap items-center gap-3 text-2xl font-semibold text-zinc-900">
|
||||
{page.title}
|
||||
<span className="rounded-full bg-zinc-900 px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wider text-white">
|
||||
{page.lang}
|
||||
</span>
|
||||
</h1>
|
||||
<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">
|
||||
<EditorForm page={page} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,158 +0,0 @@
|
|||
import Link from "next/link";
|
||||
import { requireRole } from "@/lib/authorization";
|
||||
import { UserRole } from "@/generated/prisma/enums";
|
||||
import { listContentPages } from "@/lib/content-pages";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const CATEGORY_LABEL: Record<string, string> = {
|
||||
general: "Général",
|
||||
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() {
|
||||
await requireRole([UserRole.ADMIN]);
|
||||
const rows = await listContentPages();
|
||||
|
||||
// Regrouper par slug — chaque slug peut avoir plusieurs traductions.
|
||||
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);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short", year: "2-digit" });
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl">
|
||||
<header className="mb-5 mt-2">
|
||||
<h1 className="text-2xl font-semibold text-zinc-900">Pages éditoriales</h1>
|
||||
<p className="mt-2 text-sm text-zinc-600">
|
||||
Pages markdown servies par le site public. Chaque page existe en une ou
|
||||
plusieurs langues — utilisez le bouton de la langue voulue pour éditer
|
||||
la bonne version.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="space-y-8">
|
||||
{Object.entries(byCategory).map(([cat, list]) => (
|
||||
<section key={cat}>
|
||||
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wider text-zinc-500">
|
||||
{CATEGORY_LABEL[cat] ?? cat}
|
||||
</h2>
|
||||
<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">Slug</th>
|
||||
<th className="px-4 py-2 text-left font-semibold">Titre (FR)</th>
|
||||
<th className="px-4 py-2 text-left font-semibold">Traductions</th>
|
||||
<th className="px-4 py-2 text-right font-semibold">MAJ</th>
|
||||
<th className="px-4 py-2 text-right font-semibold">Éditer</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-100">
|
||||
{list.map((p) => {
|
||||
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));
|
||||
const lastUpdated = p.translations
|
||||
.map((t) => t.updatedAt.getTime())
|
||||
.reduce((a, b) => Math.max(a, b), 0);
|
||||
return (
|
||||
<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>
|
||||
))}
|
||||
</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,24 +0,0 @@
|
|||
import type { ReactNode } from "react";
|
||||
import { requireRole } from "@/lib/authorization";
|
||||
import { UserRole } from "@/generated/prisma/enums";
|
||||
import { Sidebar } from "@/components/admin/Sidebar";
|
||||
import { TopBar } from "@/components/admin/TopBar";
|
||||
import { Breadcrumbs } from "@/components/admin/Breadcrumbs";
|
||||
import { CommandPalette } from "@/components/admin/CommandPalette";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AdminLayout({ children }: { children: ReactNode }) {
|
||||
const session = await requireRole([UserRole.ADMIN]);
|
||||
return (
|
||||
<div data-admin className="flex h-screen w-full overflow-hidden bg-zinc-50">
|
||||
<Sidebar />
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<TopBar userEmail={session.user.email ?? ""} />
|
||||
<Breadcrumbs />
|
||||
<main className="flex-1 overflow-y-auto px-4 pb-12 pt-3">{children}</main>
|
||||
</div>
|
||||
<CommandPalette />
|
||||
</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,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,90 +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 { deleteOrganizationAction, updateOrganizationAction } from "../actions";
|
||||
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);
|
||||
};
|
||||
|
||||
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 text-2xl font-semibold text-zinc-900">{org.name}</h1>
|
||||
<p className="mt-1 text-sm text-zinc-500">
|
||||
<code>/{org.slug}</code> · {org.members.length} membre{org.members.length > 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
<DeleteOrgButton action={deleteThis} memberCount={org.members.length} />
|
||||
</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,89 +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.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 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,89 +0,0 @@
|
|||
import Link from "next/link";
|
||||
import { listOrganizationsAdmin } from "@/lib/admin/organizations";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type PageProps = {
|
||||
searchParams: Promise<{ q?: string }>;
|
||||
};
|
||||
|
||||
export default async function OrgsAdminPage({ searchParams }: PageProps) {
|
||||
const sp = await searchParams;
|
||||
const filters = { q: sp.q?.trim() || undefined };
|
||||
const orgs = await listOrganizationsAdmin(filters);
|
||||
const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short", year: "2-digit" });
|
||||
|
||||
return (
|
||||
<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>
|
||||
|
||||
<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, 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">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={4} 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 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,103 +1,14 @@
|
|||
import Link from "next/link";
|
||||
import { formatEur, getAdminKpis } from "@/lib/admin/kpis";
|
||||
import { KPICard } from "@/components/admin/KPICard";
|
||||
import { requireRole } from "@/lib/authorization";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AdminDashboard() {
|
||||
const kpis = await getAdminKpis();
|
||||
export default async function AdminPage() {
|
||||
const session = await requireRole(["ADMIN"]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<header className="mb-6 mt-2">
|
||||
<h1 className="text-2xl font-semibold text-zinc-900">Tableau de bord</h1>
|
||||
<p className="mt-1 text-sm text-zinc-500">
|
||||
Vue d'ensemble de l'activité Karbé. Données live (cache 0).
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<KPICard
|
||||
label="Réservations cette semaine"
|
||||
value={kpis.bookingsThisWeek}
|
||||
hint="Toutes statuts confondus, démarrage dans la semaine en cours."
|
||||
tone="info"
|
||||
/>
|
||||
<KPICard
|
||||
label="Réservations confirmées · 30 j"
|
||||
value={kpis.bookingsConfirmed30d}
|
||||
hint="CONFIRMED + paiement SUCCEEDED, démarrage J-30."
|
||||
tone="ok"
|
||||
/>
|
||||
<KPICard
|
||||
label="Revenus reversés · 30 j"
|
||||
value={formatEur(kpis.revenue30dCents)}
|
||||
hint="Somme des montants confirmés (reversement loueurs)."
|
||||
tone="ok"
|
||||
/>
|
||||
<KPICard
|
||||
label="Occupation moyenne · 30 j"
|
||||
value={`${kpis.occupancyPct} %`}
|
||||
hint="Nuits réservées / (carbets publiés × 30)."
|
||||
tone={kpis.occupancyPct > 50 ? "ok" : "neutral"}
|
||||
/>
|
||||
<KPICard
|
||||
label="Nouveaux comptes · 30 j"
|
||||
value={kpis.newUsers30d}
|
||||
hint="Inscriptions tous rôles confondus."
|
||||
tone="info"
|
||||
/>
|
||||
<KPICard
|
||||
label="Carbets publiés"
|
||||
value={kpis.publishedCarbets}
|
||||
hint="Catalogue actif (status PUBLISHED)."
|
||||
tone="neutral"
|
||||
/>
|
||||
<KPICard
|
||||
label="Avis à modérer"
|
||||
value={kpis.reviewsToModerate}
|
||||
hint="Aucune réponse de l'hôte enregistrée."
|
||||
tone={kpis.reviewsToModerate > 5 ? "warn" : "neutral"}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="mt-10 rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-zinc-500">
|
||||
Raccourcis fréquents
|
||||
</h2>
|
||||
<ul className="mt-3 grid grid-cols-1 gap-2 text-sm sm:grid-cols-2 lg:grid-cols-3">
|
||||
<li>
|
||||
<Link 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
|
||||
</Link>
|
||||
</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">
|
||||
Voir les réservations
|
||||
</Link>
|
||||
</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">
|
||||
Éditer les pages
|
||||
</Link>
|
||||
</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">
|
||||
Activer / désactiver des plugins
|
||||
</Link>
|
||||
</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">
|
||||
Modérer les utilisateurs
|
||||
</Link>
|
||||
</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">
|
||||
Paramètres
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
<main className="mx-auto max-w-4xl px-6 py-12">
|
||||
<h1 className="text-3xl font-semibold">Espace administrateur</h1>
|
||||
<p className="mt-4 text-zinc-700">
|
||||
Accès autorisé pour {session.user.email} ({session.user.role}).
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,102 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
|
||||
interface PluginRow {
|
||||
key: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
version: string;
|
||||
enabled: boolean;
|
||||
config: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const CATEGORY_LABEL: Record<string, string> = {
|
||||
visual: "Visuels",
|
||||
business: "Métier",
|
||||
content: "Contenus",
|
||||
i18n: "Internationalisation",
|
||||
core: "Core",
|
||||
};
|
||||
|
||||
export default function PluginToggleTable({ plugins: initial }: { plugins: PluginRow[] }) {
|
||||
const [plugins, setPlugins] = useState(initial);
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [busyKey, setBusyKey] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const byCategory = plugins.reduce<Record<string, PluginRow[]>>((acc, p) => {
|
||||
(acc[p.category] ??= []).push(p);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
async function toggle(key: string, next: boolean) {
|
||||
setError(null);
|
||||
setBusyKey(key);
|
||||
try {
|
||||
const res = await fetch(`/api/admin/plugins/${encodeURIComponent(key)}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enabled: next }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body?.error || `HTTP ${res.status}`);
|
||||
}
|
||||
const updated = await res.json();
|
||||
startTransition(() => {
|
||||
setPlugins((curr) =>
|
||||
curr.map((p) => (p.key === key ? { ...p, enabled: !!updated.enabled } : p)),
|
||||
);
|
||||
});
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusyKey(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{error && (
|
||||
<div className="rounded-md border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{Object.entries(byCategory).map(([category, rows]) => (
|
||||
<section key={category}>
|
||||
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-gray-500">
|
||||
{CATEGORY_LABEL[category] ?? category}
|
||||
</h2>
|
||||
<ul className="divide-y divide-gray-200 rounded-lg border border-gray-200 bg-white">
|
||||
{rows.map((p) => (
|
||||
<li key={p.key} className="flex items-start justify-between gap-4 px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-gray-900">{p.name}</span>
|
||||
<code className="rounded bg-gray-100 px-1.5 py-0.5 text-xs text-gray-600">{p.key}</code>
|
||||
<span className="text-xs text-gray-400">v{p.version}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-gray-600">{p.description}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggle(p.key, !p.enabled)}
|
||||
disabled={pending || busyKey === p.key}
|
||||
className={`shrink-0 rounded-full px-3 py-1 text-xs font-semibold transition ${
|
||||
p.enabled
|
||||
? "bg-green-600 text-white hover:bg-green-700"
|
||||
: "bg-gray-200 text-gray-700 hover:bg-gray-300"
|
||||
} disabled:opacity-50`}
|
||||
>
|
||||
{busyKey === p.key ? "…" : p.enabled ? "Activé" : "Désactivé"}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
import { requireRole } from "@/lib/authorization";
|
||||
import { UserRole } from "@/generated/prisma/enums";
|
||||
import { listAllPlugins, syncPluginsFromRegistry } from "@/lib/plugins/server";
|
||||
import PluginToggleTable from "./_components/PluginToggleTable";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function PluginsAdminPage() {
|
||||
await requireRole([UserRole.ADMIN]);
|
||||
// S'assure que tous les plugins du registry sont en DB.
|
||||
await syncPluginsFromRegistry();
|
||||
const plugins = await listAllPlugins();
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<h1 className="text-2xl font-semibold">Plugins Karbé</h1>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
Active ou désactive chaque module. Les changements prennent effet immédiatement (cache 5 s).
|
||||
L'onEnable/onDisable est exécuté avant la bascule.
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<PluginToggleTable plugins={plugins} />
|
||||
</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,83 +0,0 @@
|
|||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
|
||||
import { StatusBadge } from "@/components/admin/StatusBadge";
|
||||
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">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,134 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { deleteReviewAction, updateReviewAction } from "../../actions";
|
||||
import { inputCls, textareaCls } from "@/components/admin/FormField";
|
||||
|
||||
type Props = {
|
||||
id: string;
|
||||
initial: {
|
||||
rating: number;
|
||||
comment: string | null;
|
||||
hostResponse: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
export function ReviewForm({ id, initial }: Props) {
|
||||
const router = useRouter();
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
|
||||
function onSubmit(formData: FormData) {
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
startTransition(async () => {
|
||||
const res = await updateReviewAction(id, formData);
|
||||
if (res && res.ok === false) {
|
||||
setError(res.error);
|
||||
} else {
|
||||
setSuccess("Avis enregistré.");
|
||||
router.refresh();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function onDelete() {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
await deleteReviewAction(id);
|
||||
router.push("/admin/reviews");
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={onSubmit} className="space-y-4">
|
||||
<fieldset disabled={pending} className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="text-[11px] uppercase tracking-wider text-zinc-500">Note</label>
|
||||
<select name="rating" defaultValue={String(initial.rating)} className={inputCls + " w-24"}>
|
||||
{[1, 2, 3, 4, 5].map((n) => (
|
||||
<option key={n} value={String(n)}>{n} ★</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-[11px] uppercase tracking-wider text-zinc-500">
|
||||
Commentaire du voyageur
|
||||
</label>
|
||||
<textarea
|
||||
name="comment"
|
||||
rows={5}
|
||||
defaultValue={initial.comment ?? ""}
|
||||
maxLength={5000}
|
||||
className={textareaCls}
|
||||
placeholder="(vide pour supprimer le commentaire)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-[11px] uppercase tracking-wider text-zinc-500">
|
||||
Réponse de l'hôte
|
||||
</label>
|
||||
<textarea
|
||||
name="hostResponse"
|
||||
rows={4}
|
||||
defaultValue={initial.hostResponse ?? ""}
|
||||
maxLength={5000}
|
||||
className={textareaCls}
|
||||
placeholder="(vide pour supprimer la réponse)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{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-between gap-2">
|
||||
{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 définitivement ?</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
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 l'avis
|
||||
</button>
|
||||
)}
|
||||
<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"}
|
||||
</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { getReviewForAdmin } from "@/lib/admin/reviews";
|
||||
import { ReviewForm } from "./_components/ReviewForm";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type PageProps = { params: Promise<{ id: string }> };
|
||||
|
||||
export default async function ReviewDetailPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
const review = await getReviewForAdmin(id);
|
||||
if (!review) notFound();
|
||||
|
||||
const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "long", year: "numeric" });
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl space-y-6">
|
||||
<header className="mt-2">
|
||||
<Link href="/admin/reviews" className="text-xs text-zinc-500 hover:text-zinc-900">
|
||||
← Tous les avis
|
||||
</Link>
|
||||
<h1 className="mt-1 text-2xl font-semibold text-zinc-900">
|
||||
Avis de {review.author.firstName} {review.author.lastName}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-zinc-500">
|
||||
Sur{" "}
|
||||
<Link href={`/admin/carbets/${review.carbet.id}`} className="text-zinc-900 hover:underline">
|
||||
{review.carbet.title}
|
||||
</Link>{" "}
|
||||
· réservation{" "}
|
||||
<Link href={`/admin/bookings/${review.booking.id}`} className="font-mono text-zinc-900 hover:underline">
|
||||
{review.booking.id.slice(0, 12)}…
|
||||
</Link>{" "}
|
||||
· publié le {dateFmt.format(review.createdAt)}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
||||
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">Modération</h2>
|
||||
<ReviewForm
|
||||
id={review.id}
|
||||
initial={{
|
||||
rating: review.rating,
|
||||
comment: review.comment,
|
||||
hostResponse: review.hostResponse,
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,60 +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 { 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.reviews", event, target, actorEmail: actor, details });
|
||||
}
|
||||
|
||||
const updateSchema = z.object({
|
||||
rating: z.coerce.number().int().min(1).max(5),
|
||||
comment: z.string().trim().max(5000).optional().nullable(),
|
||||
hostResponse: z.string().trim().max(5000).optional().nullable(),
|
||||
});
|
||||
|
||||
export async function updateReviewAction(id: string, fd: FormData) {
|
||||
await requireRole([UserRole.ADMIN]);
|
||||
const obj = Object.fromEntries(fd.entries());
|
||||
const parsed = updateSchema.safeParse({
|
||||
rating: obj.rating,
|
||||
comment: obj.comment === "" ? null : obj.comment,
|
||||
hostResponse: obj.hostResponse === "" ? null : obj.hostResponse,
|
||||
});
|
||||
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 current = await prisma.review.findUnique({ where: { id }, select: { hostResponse: true, hostRespondedAt: true } });
|
||||
const hostRespondedAt =
|
||||
parsed.data.hostResponse && parsed.data.hostResponse !== current?.hostResponse
|
||||
? new Date()
|
||||
: current?.hostRespondedAt ?? null;
|
||||
await prisma.review.update({
|
||||
where: { id },
|
||||
data: {
|
||||
rating: parsed.data.rating,
|
||||
comment: parsed.data.comment ?? null,
|
||||
hostResponse: parsed.data.hostResponse ?? null,
|
||||
hostRespondedAt: parsed.data.hostResponse ? hostRespondedAt : null,
|
||||
},
|
||||
});
|
||||
await audit("review.update", id, session?.user?.email ?? null, { rating: parsed.data.rating });
|
||||
revalidatePath("/admin/reviews");
|
||||
revalidatePath(`/admin/reviews/${id}`);
|
||||
return { ok: true as const };
|
||||
}
|
||||
|
||||
export async function deleteReviewAction(id: string) {
|
||||
await requireRole([UserRole.ADMIN]);
|
||||
const session = await auth();
|
||||
await prisma.review.delete({ where: { id } });
|
||||
await audit("review.delete", id, session?.user?.email ?? null, {});
|
||||
revalidatePath("/admin/reviews");
|
||||
return { ok: true as const };
|
||||
}
|
||||
|
|
@ -1,134 +0,0 @@
|
|||
import Link from "next/link";
|
||||
import { listReviewsAdmin } from "@/lib/admin/reviews";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type PageProps = {
|
||||
searchParams: Promise<{
|
||||
q?: string;
|
||||
rating?: string;
|
||||
withResponse?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
function Stars({ rating }: { rating: number }) {
|
||||
return (
|
||||
<span className="font-mono text-sm">
|
||||
<span className="text-amber-500">{"★".repeat(rating)}</span>
|
||||
<span className="text-zinc-300">{"★".repeat(5 - rating)}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function ReviewsAdminPage({ searchParams }: PageProps) {
|
||||
const sp = await searchParams;
|
||||
const rating = sp.rating && /^[1-5]$/.test(sp.rating) ? Number(sp.rating) : undefined;
|
||||
const withResponse = sp.withResponse === "yes" || sp.withResponse === "no" ? (sp.withResponse as "yes" | "no") : undefined;
|
||||
const filters = {
|
||||
q: sp.q?.trim() || undefined,
|
||||
rating,
|
||||
withResponse,
|
||||
};
|
||||
const reviews = await listReviewsAdmin(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 flex items-end justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-zinc-900">Avis & modération</h1>
|
||||
<p className="mt-1 text-sm text-zinc-500">
|
||||
{reviews.length} résultat{reviews.length > 1 ? "s" : ""}
|
||||
{reviews.length === 200 ? " (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 commentaire, auteur, carbet…"
|
||||
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="rating"
|
||||
defaultValue={sp.rating ?? ""}
|
||||
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 notes</option>
|
||||
{[5, 4, 3, 2, 1].map((r) => (
|
||||
<option key={r} value={String(r)}>{r} étoile{r > 1 ? "s" : ""}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
name="withResponse"
|
||||
defaultValue={filters.withResponse ?? ""}
|
||||
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="">Avec ou sans réponse</option>
|
||||
<option value="yes">Avec réponse hôte</option>
|
||||
<option value="no">Sans réponse hôte</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.rating || filters.withResponse) ? (
|
||||
<Link href="/admin/reviews" className="text-sm text-zinc-500 hover:text-zinc-900">
|
||||
Réinit.
|
||||
</Link>
|
||||
) : null}
|
||||
</form>
|
||||
|
||||
<div className="space-y-3">
|
||||
{reviews.length === 0 ? (
|
||||
<div className="rounded-lg border border-zinc-200 bg-white px-4 py-8 text-center text-sm text-zinc-500">
|
||||
Aucun avis ne correspond aux filtres.
|
||||
</div>
|
||||
) : null}
|
||||
{reviews.map((r) => (
|
||||
<article key={r.id} className="rounded-lg border border-zinc-200 bg-white p-4 shadow-sm">
|
||||
<header className="mb-2 flex flex-wrap items-baseline justify-between gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<Stars rating={r.rating} />
|
||||
<Link href={`/admin/reviews/${r.id}`} className="text-sm font-semibold text-zinc-900 hover:underline">
|
||||
{r.author.firstName} {r.author.lastName}
|
||||
</Link>
|
||||
<span className="text-[11px] text-zinc-500">{r.author.email}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[11px] text-zinc-500">
|
||||
<Link href={`/admin/carbets/${r.carbet.id}`} className="hover:text-zinc-900 hover:underline">
|
||||
{r.carbet.title}
|
||||
</Link>
|
||||
· <Link href={`/admin/bookings/${r.booking.id}`} className="font-mono hover:text-zinc-900 hover:underline">
|
||||
résa {r.booking.id.slice(0, 8)}…
|
||||
</Link>
|
||||
· {dateFmt.format(r.createdAt)}
|
||||
</div>
|
||||
</header>
|
||||
{r.comment ? (
|
||||
<p className="whitespace-pre-line text-sm text-zinc-800">{r.comment}</p>
|
||||
) : (
|
||||
<p className="text-sm italic text-zinc-400">Pas de commentaire.</p>
|
||||
)}
|
||||
{r.hostResponse ? (
|
||||
<div className="mt-2 rounded border border-emerald-100 bg-emerald-50 px-3 py-2 text-sm text-emerald-900">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-wider text-emerald-700">Réponse hôte</div>
|
||||
<p className="whitespace-pre-line">{r.hostResponse}</p>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-2 flex items-center justify-end">
|
||||
<Link
|
||||
href={`/admin/reviews/${r.id}`}
|
||||
className="text-xs font-semibold text-zinc-700 hover:text-zinc-900 hover:underline"
|
||||
>
|
||||
Modérer →
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,120 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { UserRole } from "@/generated/prisma/enums";
|
||||
import { toggleUserActiveAction, updateUserRoleAction } from "../../actions";
|
||||
|
||||
const ROLE_OPTIONS: { value: string; label: string }[] = [
|
||||
{ value: UserRole.OWNER, label: "Propriétaire" },
|
||||
{ value: UserRole.CE_MANAGER, label: "CE — Manager" },
|
||||
{ value: UserRole.CE_MEMBER, label: "CE — Membre" },
|
||||
{ value: UserRole.TOURIST, label: "Touriste" },
|
||||
{ value: UserRole.ADMIN, label: "Admin" },
|
||||
];
|
||||
|
||||
export function UserActions({
|
||||
id,
|
||||
role,
|
||||
isActive,
|
||||
}: {
|
||||
id: string;
|
||||
role: string;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedRole, setSelectedRole] = useState(role);
|
||||
const [confirmDeactivate, setConfirmDeactivate] = useState(false);
|
||||
|
||||
function changeRole(next: string) {
|
||||
setError(null);
|
||||
setSelectedRole(next);
|
||||
startTransition(async () => {
|
||||
const res = await updateUserRoleAction(id, next);
|
||||
if (res && res.ok === false) {
|
||||
setError(res.error);
|
||||
setSelectedRole(role);
|
||||
}
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
function toggleActive(next: boolean) {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const res = await toggleUserActiveAction(id, next);
|
||||
if (res && res.ok === false) setError(res.error);
|
||||
setConfirmDeactivate(false);
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<label className="text-[11px] uppercase tracking-wider text-zinc-500">Rôle</label>
|
||||
<select
|
||||
value={selectedRole}
|
||||
disabled={pending}
|
||||
onChange={(e) => changeRole(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 disabled:opacity-50"
|
||||
>
|
||||
{ROLE_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-[11px] uppercase tracking-wider text-zinc-500">État du compte</span>
|
||||
{isActive ? (
|
||||
confirmDeactivate ? (
|
||||
<div className="flex items-center gap-2 rounded border border-amber-300 bg-amber-50 px-2 py-1">
|
||||
<span className="text-xs text-amber-900">Désactiver ce compte ?</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleActive(false)}
|
||||
disabled={pending}
|
||||
className="rounded bg-amber-700 px-2 py-1 text-[11px] font-semibold text-white hover:bg-amber-800 disabled:opacity-50"
|
||||
>
|
||||
Oui, désactiver
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmDeactivate(false)}
|
||||
disabled={pending}
|
||||
className="text-[11px] text-zinc-500 hover:text-zinc-900"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmDeactivate(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"
|
||||
>
|
||||
Désactiver
|
||||
</button>
|
||||
)
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleActive(true)}
|
||||
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"
|
||||
>
|
||||
Réactiver
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">{error}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,133 +0,0 @@
|
|||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { getUserForAdmin } from "@/lib/admin/users";
|
||||
import { StatusBadge } from "@/components/admin/StatusBadge";
|
||||
import { UserActions } from "./_components/UserActions";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type PageProps = { params: Promise<{ id: string }> };
|
||||
|
||||
const ROLE_LABEL: Record<string, string> = {
|
||||
OWNER: "Propriétaire",
|
||||
CE_MANAGER: "CE — Manager",
|
||||
CE_MEMBER: "CE — Membre",
|
||||
TOURIST: "Touriste",
|
||||
ADMIN: "Admin",
|
||||
};
|
||||
|
||||
export default async function UserDetailPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
const user = await getUserForAdmin(id);
|
||||
if (!user) notFound();
|
||||
|
||||
const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "long", year: "numeric" });
|
||||
const dateShortFmt = 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">
|
||||
<Link href="/admin/users" className="text-xs text-zinc-500 hover:text-zinc-900">
|
||||
← Tous les utilisateurs
|
||||
</Link>
|
||||
<h1 className="mt-1 flex items-center gap-3 text-2xl font-semibold text-zinc-900">
|
||||
{user.firstName} {user.lastName}
|
||||
<StatusBadge status={user.isActive ? "ACTIVE" : "INACTIVE"} />
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-zinc-500">
|
||||
{user.email} · {ROLE_LABEL[user.role] ?? user.role} · inscrit le {dateFmt.format(user.createdAt)}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
||||
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">Actions</h2>
|
||||
<UserActions id={user.id} role={user.role} isActive={user.isActive} />
|
||||
</section>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<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">Identité</h2>
|
||||
<dl className="space-y-2 text-sm">
|
||||
<Row label="Email" value={user.email} />
|
||||
{user.phone ? <Row label="Téléphone" value={user.phone} /> : null}
|
||||
<Row label="Rôle" value={ROLE_LABEL[user.role] ?? user.role} />
|
||||
<Row label="Actif" value={user.isActive ? "Oui" : "Non"} />
|
||||
{user.organization ? (
|
||||
<Row
|
||||
label="Organisation"
|
||||
value={
|
||||
<Link href={`/admin/organizations/${user.organization.id}`} className="text-zinc-900 hover:underline">
|
||||
{user.organization.name}
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</dl>
|
||||
</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">Statistiques</h2>
|
||||
<dl className="space-y-2 text-sm">
|
||||
<Row label="Carbets" value={String(user._count.carbets)} />
|
||||
<Row label="Réservations" value={String(user._count.bookings)} />
|
||||
<Row label="Avis publiés" value={String(user._count.reviews)} />
|
||||
<Row label="Abonnements" value={String(user._count.subscriptions)} />
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{user.carbets.length > 0 ? (
|
||||
<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">Carbets du propriétaire</h2>
|
||||
<ul className="space-y-1.5">
|
||||
{user.carbets.map((c) => (
|
||||
<li key={c.id} className="flex items-center justify-between text-sm">
|
||||
<Link href={`/admin/carbets/${c.id}`} className="text-zinc-900 hover:underline">
|
||||
{c.title} <code className="text-[11px] text-zinc-500">/{c.slug}</code>
|
||||
</Link>
|
||||
<span className="flex items-center gap-2">
|
||||
<StatusBadge status={c.status} />
|
||||
<span className="text-[11px] text-zinc-500">{dateShortFmt.format(c.updatedAt)}</span>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{user.bookings.length > 0 ? (
|
||||
<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">Dernières réservations</h2>
|
||||
<ul className="space-y-1.5">
|
||||
{user.bookings.map((b) => (
|
||||
<li key={b.id} className="flex items-center justify-between gap-3 text-sm">
|
||||
<Link href={`/admin/bookings/${b.id}`} className="text-zinc-900 hover:underline">
|
||||
{b.carbet.title}
|
||||
<span className="ml-2 text-[11px] text-zinc-500">
|
||||
{dateShortFmt.format(b.startDate)} → {dateShortFmt.format(b.endDate)}
|
||||
</span>
|
||||
</Link>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="font-mono text-[11px] text-zinc-700">
|
||||
{Number(b.amount).toFixed(2)} {b.currency}
|
||||
</span>
|
||||
<StatusBadge status={b.status} />
|
||||
<StatusBadge status={b.paymentStatus} />
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-baseline justify-between gap-3 border-b border-zinc-100 pb-1.5 last:border-b-0 last:pb-0">
|
||||
<dt className="text-[11px] uppercase tracking-wider text-zinc-500">{label}</dt>
|
||||
<dd className="text-sm text-zinc-900">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
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";
|
||||
|
||||
const ROLE_VALUES = new Set<string>([
|
||||
UserRole.OWNER,
|
||||
UserRole.CE_MANAGER,
|
||||
UserRole.CE_MEMBER,
|
||||
UserRole.TOURIST,
|
||||
UserRole.ADMIN,
|
||||
]);
|
||||
|
||||
async function audit(event: string, target: string, actor: string | null, details: Record<string, unknown>) {
|
||||
await recordAudit({ scope: "admin.users", event, target, actorEmail: actor, details });
|
||||
}
|
||||
|
||||
export async function updateUserRoleAction(id: string, role: string) {
|
||||
await requireRole([UserRole.ADMIN]);
|
||||
if (!ROLE_VALUES.has(role)) {
|
||||
return { ok: false as const, error: "Rôle invalide" };
|
||||
}
|
||||
const session = await auth();
|
||||
if (role !== UserRole.ADMIN) {
|
||||
const adminCount = await prisma.user.count({ where: { role: UserRole.ADMIN, isActive: true } });
|
||||
const current = await prisma.user.findUnique({ where: { id }, select: { role: true } });
|
||||
if (current?.role === UserRole.ADMIN && adminCount <= 1) {
|
||||
return { ok: false as const, error: "Impossible de retirer le dernier admin actif." };
|
||||
}
|
||||
}
|
||||
await prisma.user.update({ where: { id }, data: { role: role as UserRole } });
|
||||
await audit("user.role.update", id, session?.user?.email ?? null, { role });
|
||||
revalidatePath("/admin/users");
|
||||
revalidatePath(`/admin/users/${id}`);
|
||||
return { ok: true as const };
|
||||
}
|
||||
|
||||
export async function toggleUserActiveAction(id: string, active: boolean) {
|
||||
await requireRole([UserRole.ADMIN]);
|
||||
const session = await auth();
|
||||
if (!active) {
|
||||
const target = await prisma.user.findUnique({ where: { id }, select: { role: true, isActive: true } });
|
||||
if (target?.role === UserRole.ADMIN) {
|
||||
const adminCount = await prisma.user.count({ where: { role: UserRole.ADMIN, isActive: true } });
|
||||
if (adminCount <= 1) {
|
||||
return { ok: false as const, error: "Impossible de désactiver le dernier admin." };
|
||||
}
|
||||
}
|
||||
}
|
||||
await prisma.user.update({ where: { id }, data: { isActive: active } });
|
||||
await audit("user.active.update", id, session?.user?.email ?? null, { active });
|
||||
revalidatePath("/admin/users");
|
||||
revalidatePath(`/admin/users/${id}`);
|
||||
return { ok: true as const };
|
||||
}
|
||||
|
|
@ -1,136 +0,0 @@
|
|||
import Link from "next/link";
|
||||
import { UserRole } from "@/generated/prisma/enums";
|
||||
import { listUsersAdmin } from "@/lib/admin/users";
|
||||
import { StatusBadge } from "@/components/admin/StatusBadge";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type PageProps = {
|
||||
searchParams: Promise<{
|
||||
q?: string;
|
||||
role?: string;
|
||||
active?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
const ROLE_VALUES = new Set<string>([
|
||||
UserRole.OWNER,
|
||||
UserRole.CE_MANAGER,
|
||||
UserRole.CE_MEMBER,
|
||||
UserRole.TOURIST,
|
||||
UserRole.ADMIN,
|
||||
]);
|
||||
|
||||
const ROLE_LABEL: Record<string, string> = {
|
||||
OWNER: "Propriétaire",
|
||||
CE_MANAGER: "CE — Manager",
|
||||
CE_MEMBER: "CE — Membre",
|
||||
TOURIST: "Touriste",
|
||||
ADMIN: "Admin",
|
||||
};
|
||||
|
||||
export default async function UsersAdminPage({ searchParams }: PageProps) {
|
||||
const sp = await searchParams;
|
||||
const filters = {
|
||||
q: sp.q?.trim() || undefined,
|
||||
role: ROLE_VALUES.has(sp.role ?? "") ? (sp.role as UserRole) : undefined,
|
||||
active: sp.active === "yes" || sp.active === "no" ? (sp.active as "yes" | "no") : undefined,
|
||||
};
|
||||
const users = await listUsersAdmin(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 flex items-end justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-zinc-900">Utilisateurs</h1>
|
||||
<p className="mt-1 text-sm text-zinc-500">
|
||||
{users.length} résultat{users.length > 1 ? "s" : ""}
|
||||
{users.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 email, nom, téléphone…"
|
||||
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="role"
|
||||
defaultValue={filters.role ?? ""}
|
||||
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 rôles</option>
|
||||
{Object.entries(ROLE_LABEL).map(([v, l]) => (
|
||||
<option key={v} value={v}>{l}</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.role || filters.active) ? (
|
||||
<Link href="/admin/users" 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">Email</th>
|
||||
<th className="px-4 py-2 text-left font-semibold">Rôle</th>
|
||||
<th className="px-4 py-2 text-right font-semibold">Carbets</th>
|
||||
<th className="px-4 py-2 text-right font-semibold">Résas</th>
|
||||
<th className="px-4 py-2 text-right font-semibold">Avis</th>
|
||||
<th className="px-4 py-2 text-left font-semibold">État</th>
|
||||
<th className="px-4 py-2 text-right font-semibold">Inscrit</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-100">
|
||||
{users.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-4 py-8 text-center text-sm text-zinc-500">
|
||||
Aucun utilisateur ne correspond aux filtres.
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{users.map((u) => (
|
||||
<tr key={u.id} className="hover:bg-zinc-50">
|
||||
<td className="px-4 py-2">
|
||||
<Link href={`/admin/users/${u.id}`} className="font-medium text-zinc-900 hover:underline">
|
||||
{u.firstName} {u.lastName}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-zinc-700">{u.email}</td>
|
||||
<td className="px-4 py-2 text-zinc-700">{ROLE_LABEL[u.role] ?? u.role}</td>
|
||||
<td className="px-4 py-2 text-right font-mono text-zinc-700">{u.carbetsCount}</td>
|
||||
<td className="px-4 py-2 text-right font-mono text-zinc-700">{u.bookingsCount}</td>
|
||||
<td className="px-4 py-2 text-right font-mono text-zinc-700">{u.reviewsCount}</td>
|
||||
<td className="px-4 py-2"><StatusBadge status={u.isActive ? "ACTIVE" : "INACTIVE"} /></td>
|
||||
<td className="px-4 py-2 text-right text-[11px] text-zinc-500">
|
||||
{dateFmt.format(u.createdAt)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { requireRole } from "@/lib/authorization";
|
||||
import { UserRole } from "@/generated/prisma/enums";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(_req: Request, ctx: { params: Promise<{ id: string }> }) {
|
||||
await requireRole([UserRole.ADMIN]);
|
||||
const { id } = await ctx.params;
|
||||
const media = await prisma.media.findMany({
|
||||
where: { carbetId: id },
|
||||
orderBy: { sortOrder: "asc" },
|
||||
select: { id: true, type: true, s3Key: true, s3Url: true, sortOrder: true },
|
||||
});
|
||||
return NextResponse.json(media);
|
||||
}
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/auth";
|
||||
import { requireRole } from "@/lib/authorization";
|
||||
import { UserRole } from "@/generated/prisma/enums";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
const patchSchema = z.object({
|
||||
title: z.string().min(1).max(200).optional(),
|
||||
body: z.string().max(100_000).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 }> }) {
|
||||
await requireRole([UserRole.ADMIN]);
|
||||
const { slug } = await ctx.params;
|
||||
const url = new URL(req.url);
|
||||
const lang = normalizeLang(url.searchParams.get("lang"));
|
||||
const session = await auth();
|
||||
const parsed = patchSchema.safeParse(await req.json().catch(() => ({})));
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Invalid payload" }, { status: 400 });
|
||||
}
|
||||
const existing = await prisma.contentPage.findUnique({
|
||||
where: { slug_lang: { slug, lang } },
|
||||
});
|
||||
if (!existing) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
const updated = await prisma.contentPage.update({
|
||||
where: { slug_lang: { slug, lang } },
|
||||
data: {
|
||||
...(parsed.data.title !== undefined ? { title: parsed.data.title } : {}),
|
||||
...(parsed.data.body !== undefined ? { body: parsed.data.body } : {}),
|
||||
...(parsed.data.published !== undefined ? { published: parsed.data.published } : {}),
|
||||
lastEditedBy: session?.user?.email ?? session?.user?.id ?? null,
|
||||
},
|
||||
});
|
||||
return NextResponse.json({
|
||||
slug: updated.slug,
|
||||
lang: updated.lang,
|
||||
title: updated.title,
|
||||
published: updated.published,
|
||||
updatedAt: updated.updatedAt,
|
||||
});
|
||||
}
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { requireRole } from "@/lib/authorization";
|
||||
import { UserRole } from "@/generated/prisma/enums";
|
||||
import { findDescriptor } from "@/lib/plugins/registry";
|
||||
import { togglePlugin, updatePluginConfig, getPluginState } from "@/lib/plugins/server";
|
||||
|
||||
const patchSchema = z.object({
|
||||
enabled: z.boolean().optional(),
|
||||
config: z.record(z.string(), z.unknown()).optional(),
|
||||
});
|
||||
|
||||
export async function PATCH(req: Request, ctx: { params: Promise<{ key: string }> }) {
|
||||
await requireRole([UserRole.ADMIN]);
|
||||
const { key } = await ctx.params;
|
||||
if (!findDescriptor(key)) {
|
||||
return NextResponse.json({ error: "Unknown plugin" }, { status: 404 });
|
||||
}
|
||||
const parsed = patchSchema.safeParse(await req.json().catch(() => ({})));
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Invalid payload" }, { status: 400 });
|
||||
}
|
||||
let state = await getPluginState(key);
|
||||
if (parsed.data.enabled !== undefined) {
|
||||
state = await togglePlugin(key, parsed.data.enabled);
|
||||
}
|
||||
if (parsed.data.config !== undefined) {
|
||||
state = await updatePluginConfig(key, parsed.data.config);
|
||||
}
|
||||
return NextResponse.json(state);
|
||||
}
|
||||
|
||||
export async function GET(_req: Request, ctx: { params: Promise<{ key: string }> }) {
|
||||
await requireRole([UserRole.ADMIN]);
|
||||
const { key } = await ctx.params;
|
||||
const state = await getPluginState(key);
|
||||
if (!state) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
return NextResponse.json(state);
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { requireRole } from "@/lib/authorization";
|
||||
import { UserRole } from "@/generated/prisma/enums";
|
||||
import { adminSearch } from "@/lib/admin/search";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
await requireRole([UserRole.ADMIN]);
|
||||
const url = new URL(req.url);
|
||||
const q = url.searchParams.get("q") ?? "";
|
||||
const hits = await adminSearch(q);
|
||||
return NextResponse.json({ hits });
|
||||
}
|
||||
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