Compare commits
123 commits
feat/sys-1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d24e3b4af7 | |||
|
|
9da58288dc | ||
| d42584cc4c | |||
|
|
15f41a7e2a | ||
| 740e9958aa | |||
|
|
5607a51980 | ||
| 0723e50189 | |||
|
|
91b4d918ea | ||
| 1165f32a63 | |||
|
|
59786e5365 | ||
| 8d7e9cfdc2 | |||
|
|
f31fb8a32c | ||
| 1dd2d65626 | |||
|
|
90cc7a94af | ||
| 46d3c2d3ab | |||
|
|
e2f3f070fa | ||
| d2dcc698e9 | |||
|
|
4901bb950e | ||
| 1f8250ad7e | |||
|
|
dc2b07507f | ||
| 153d0671c0 | |||
|
|
d5732917e3 | ||
| 5449ec9047 | |||
|
|
bc158ca144 | ||
| b8b421e839 | |||
|
|
4fb7c948ad | ||
| 3a7c325373 | |||
|
|
e2d3b6a686 | ||
| e542a853fa | |||
|
|
701a1f02bd | ||
| 403e21fe0a | |||
|
|
2545a5e1a8 | ||
| a575d40163 | |||
|
|
2914e5605a | ||
| 8285909178 | |||
|
|
71dd8c1dad | ||
| 444fd1e6fd | |||
|
|
92deffa109 | ||
| cf9ee2bd1e | |||
|
|
a373bd60ad | ||
| f1fb06b0af | |||
|
|
55c0244336 | ||
| d1a1bb04de | |||
|
|
1e6acf29b9 | ||
| 3e109fb7b4 | |||
|
|
a58815ec9c | ||
| 61ccb05c75 | |||
|
|
a6df96db7e | ||
| 0b5e5408e8 | |||
|
|
31aa7a4865 | ||
| 231416dd08 | |||
|
|
3bc52b2b60 | ||
| 4e8b88ab34 | |||
|
|
6eed6bffc8 | ||
| ccaad1d546 | |||
|
|
14fd9a5940 | ||
| 56e5c48a84 | |||
|
|
b59b8a0af2 | ||
| 4e14854245 | |||
|
|
e79b6dd141 | ||
| f09a680059 | |||
|
|
a9fcd18022 | ||
| d3cc5bdfb9 | |||
|
|
1f8dd90979 | ||
| 0244eb5029 | |||
|
|
a5ae692cf4 | ||
| c8c97e467d | |||
|
|
4e6867b365 | ||
| f9c10f151c | |||
|
|
79ddcd23f5 | ||
| 2ad4cbed80 | |||
|
|
99f3bbdc71 | ||
| 19b4ff8293 | |||
|
|
d9ee072744 | ||
| 8f31047b36 | |||
|
|
fea55a7ddb | ||
| 00a5533bea | |||
|
|
fc01144e0e | ||
| 820f7a821b | |||
|
|
9aa0771001 | ||
| 3ec7a3ff10 | |||
|
|
bcb93c6b29 | ||
| ffb39a3bf5 | |||
|
|
47258bf1be | ||
| 93aebc4e87 | |||
|
|
c69c355f90 | ||
| bb2fee7659 | |||
|
|
8196a1a3f9 | ||
| df9eb5fcbd | |||
|
|
87c3e7a581 | ||
| 88a937f2fd | |||
|
|
cf9da94bb5 | ||
| efeea16467 | |||
|
|
a174f99eba | ||
| 8c0b849ad7 | |||
|
|
68f37f554f | ||
| ae8f79b436 | |||
|
|
a7761ca323 | ||
| fdf66bfc74 | |||
|
|
3405f00476 | ||
| 32410c95c7 | |||
|
|
be2391998d | ||
| 4842a44746 | |||
|
|
bc571b38d1 | ||
| 35080dcde1 | |||
|
|
5e59202505 | ||
| 9b9963403d | |||
|
|
049d0bb423 | ||
| de9f73246b | |||
|
|
b1c2877e43 | ||
| dc05fe118b | |||
|
|
e433ebc439 | ||
| 1868b36379 | |||
|
|
26922329d4 | ||
| d3ce396b20 | |||
|
|
a564373a07 | ||
| 800a06afc6 | |||
|
|
abc3844af2 | ||
| b4617545d0 | |||
|
|
d19701e275 | ||
| 4454f7331d | |||
|
|
62cc464738 | ||
| d7de43a70e |
281 changed files with 26618 additions and 326 deletions
59
.forgejo/workflows/ci.yml
Normal file
59
.forgejo/workflows/ci.yml
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
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"
|
||||||
|
|
@ -2,13 +2,20 @@ FROM node:20-alpine AS base
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
FROM base AS deps
|
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 package.json package-lock.json ./
|
||||||
|
COPY prisma ./prisma
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY . .
|
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
|
RUN npm run build
|
||||||
|
|
||||||
FROM node:20-alpine AS runner
|
FROM node:20-alpine AS runner
|
||||||
|
|
@ -21,6 +28,8 @@ RUN addgroup -S nextjs && adduser -S nextjs -G nextjs
|
||||||
COPY --from=builder /app/public ./public
|
COPY --from=builder /app/public ./public
|
||||||
COPY --from=builder /app/.next/standalone ./
|
COPY --from=builder /app/.next/standalone ./
|
||||||
COPY --from=builder /app/.next/static ./.next/static
|
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
|
USER nextjs
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
|
||||||
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,18 +7,30 @@
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
"postinstall": "prisma generate"
|
"postinstall": "prisma generate",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.1056.0",
|
"@aws-sdk/client-s3": "^3.1056.0",
|
||||||
|
"@aws-sdk/s3-request-presigner": "^3.1058.0",
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^8.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@prisma/adapter-pg": "^7.8.0",
|
"@prisma/adapter-pg": "^7.8.0",
|
||||||
"@prisma/client": "^7.8.0",
|
"@prisma/client": "^7.8.0",
|
||||||
|
"@types/leaflet": "^1.9.21",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
"next": "16.2.6",
|
"next": "16.2.6",
|
||||||
"next-auth": "^5.0.0-beta.31",
|
"next-auth": "^5.0.0-beta.31",
|
||||||
"pg": "^8.21.0",
|
"pg": "^8.21.0",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
|
"react-leaflet": "^5.0.0",
|
||||||
|
"resend": "^4.8.0",
|
||||||
|
"sharp": "^0.34.5",
|
||||||
"stripe": "^18.3.0"
|
"stripe": "^18.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
@ -26,11 +38,13 @@
|
||||||
"@types/node": "^20.19.41",
|
"@types/node": "^20.19.41",
|
||||||
"@types/react": "^19.2.15",
|
"@types/react": "^19.2.15",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitest/coverage-v8": "^3.2.4",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"eslint": "^9.39.4",
|
"eslint": "^9.39.4",
|
||||||
"eslint-config-next": "^16.2.6",
|
"eslint-config-next": "^16.2.6",
|
||||||
"prisma": "^7.8.0",
|
"prisma": "^7.8.0",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3",
|
||||||
|
"vitest": "^3.2.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
-- 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");
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
-- 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");
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
-- 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;
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
-- 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");
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
-- 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");
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
-- 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");
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
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")
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
CREATE TABLE "PasswordResetToken" (
|
||||||
|
"tokenHash" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "PasswordResetToken_pkey" PRIMARY KEY ("tokenHash")
|
||||||
|
);
|
||||||
|
CREATE INDEX "PasswordResetToken_userId_idx" ON "PasswordResetToken"("userId");
|
||||||
|
CREATE INDEX "PasswordResetToken_expiresAt_idx" ON "PasswordResetToken"("expiresAt");
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
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");
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
ALTER TABLE "Carbet" ADD COLUMN "nightlyPrice" DECIMAL(10,2) NOT NULL DEFAULT 0;
|
||||||
|
UPDATE "Carbet" SET "nightlyPrice" = 80 WHERE "nightlyPrice" = 0;
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
CREATE TYPE "RoadAccess" AS ENUM ('NONE', 'DRY_SEASON_ONLY', 'ALL_YEAR');
|
||||||
|
CREATE TYPE "Electricity" AS ENUM ('NONE', 'SOLAR', 'GENERATOR_READY', 'EDF');
|
||||||
|
|
||||||
|
ALTER TABLE "Carbet" ADD COLUMN "roadAccess" "RoadAccess";
|
||||||
|
ALTER TABLE "Carbet" ADD COLUMN "electricity" "Electricity";
|
||||||
|
ALTER TABLE "Carbet" ADD COLUMN "gsmAtCarbet" BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
ALTER TABLE "Carbet" ADD COLUMN "gsmExitDistanceKm" DECIMAL(4,2);
|
||||||
|
|
||||||
|
-- Seed des 6 carbets démo avec valeurs réalistes
|
||||||
|
UPDATE "Carbet" SET "roadAccess" = 'NONE', "electricity" = 'SOLAR', "gsmAtCarbet" = false, "gsmExitDistanceKm" = 1.5 WHERE id = 'demo-carbet-awara';
|
||||||
|
UPDATE "Carbet" SET "roadAccess" = 'ALL_YEAR', "electricity" = 'EDF', "gsmAtCarbet" = true WHERE id = 'demo-carbet-kourou';
|
||||||
|
UPDATE "Carbet" SET "roadAccess" = 'ALL_YEAR', "electricity" = 'EDF', "gsmAtCarbet" = true WHERE id = 'demo-carbet-mahury';
|
||||||
|
UPDATE "Carbet" SET "roadAccess" = 'NONE', "electricity" = 'GENERATOR_READY', "gsmAtCarbet" = false, "gsmExitDistanceKm" = 4.0 WHERE id = 'demo-carbet-maripa';
|
||||||
|
UPDATE "Carbet" SET "roadAccess" = 'DRY_SEASON_ONLY', "electricity" = 'SOLAR', "gsmAtCarbet" = false, "gsmExitDistanceKm" = 0.5 WHERE id = 'demo-carbet-paripou';
|
||||||
|
UPDATE "Carbet" SET "roadAccess" = 'ALL_YEAR', "electricity" = 'EDF', "gsmAtCarbet" = true WHERE id = 'demo-carbet-wapa';
|
||||||
8
prisma/migrations/20260602100000_favorite/migration.sql
Normal file
8
prisma/migrations/20260602100000_favorite/migration.sql
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
CREATE TABLE "Favorite" (
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"carbetId" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "Favorite_pkey" PRIMARY KEY ("userId", "carbetId")
|
||||||
|
);
|
||||||
|
CREATE INDEX "Favorite_userId_idx" ON "Favorite"("userId");
|
||||||
|
CREATE INDEX "Favorite_carbetId_idx" ON "Favorite"("carbetId");
|
||||||
|
|
@ -0,0 +1,112 @@
|
||||||
|
-- UserRole : ajouter RENTAL_PROVIDER
|
||||||
|
ALTER TYPE "UserRole" ADD VALUE IF NOT EXISTS 'RENTAL_PROVIDER';
|
||||||
|
|
||||||
|
-- Enums dédiés
|
||||||
|
CREATE TYPE "RentalCategory" AS ENUM ('SLEEP', 'NAVIGATION', 'FISHING', 'COOKING', 'SAFETY');
|
||||||
|
CREATE TYPE "RentalBookingStatus" AS ENUM ('PENDING', 'CONFIRMED', 'HANDED_OVER', 'RETURNED', 'CANCELLED');
|
||||||
|
|
||||||
|
-- RentalProvider
|
||||||
|
CREATE TABLE "RentalProvider" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"isSystemD" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"managedByUserId" TEXT,
|
||||||
|
"contactEmail" TEXT,
|
||||||
|
"contactPhone" TEXT,
|
||||||
|
"rivers" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||||
|
"description" TEXT,
|
||||||
|
"commissionPct" DECIMAL(5,2) NOT NULL DEFAULT 0,
|
||||||
|
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"approved" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"approvedAt" TIMESTAMP(3),
|
||||||
|
"approvedBy" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "RentalProvider_pkey" PRIMARY KEY ("id"),
|
||||||
|
CONSTRAINT "RentalProvider_managedByUserId_fkey" FOREIGN KEY ("managedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX "RentalProvider_active_approved_idx" ON "RentalProvider"("active", "approved");
|
||||||
|
CREATE INDEX "RentalProvider_managedByUserId_idx" ON "RentalProvider"("managedByUserId");
|
||||||
|
|
||||||
|
-- RentalItem
|
||||||
|
CREATE TABLE "RentalItem" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"providerId" TEXT NOT NULL,
|
||||||
|
"category" "RentalCategory" NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
"imageUrl" TEXT,
|
||||||
|
"pricePerDay" DECIMAL(8,2) NOT NULL,
|
||||||
|
"pricePerWeek" DECIMAL(8,2),
|
||||||
|
"deposit" DECIMAL(8,2) NOT NULL DEFAULT 0,
|
||||||
|
"totalQty" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
"withMotor" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"fuelIncluded" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"requiresLicense" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "RentalItem_pkey" PRIMARY KEY ("id"),
|
||||||
|
CONSTRAINT "RentalItem_providerId_fkey" FOREIGN KEY ("providerId") REFERENCES "RentalProvider"("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX "RentalItem_providerId_idx" ON "RentalItem"("providerId");
|
||||||
|
CREATE INDEX "RentalItem_category_active_idx" ON "RentalItem"("category", "active");
|
||||||
|
|
||||||
|
-- RentalItemAvailability
|
||||||
|
CREATE TABLE "RentalItemAvailability" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"itemId" TEXT NOT NULL,
|
||||||
|
"startDate" TIMESTAMP(3) NOT NULL,
|
||||||
|
"endDate" TIMESTAMP(3) NOT NULL,
|
||||||
|
"qty" INTEGER NOT NULL,
|
||||||
|
"reason" TEXT NOT NULL,
|
||||||
|
"rentalBookingId" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "RentalItemAvailability_pkey" PRIMARY KEY ("id"),
|
||||||
|
CONSTRAINT "RentalItemAvailability_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "RentalItem"("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX "RentalItemAvailability_itemId_startDate_endDate_idx" ON "RentalItemAvailability"("itemId", "startDate", "endDate");
|
||||||
|
CREATE INDEX "RentalItemAvailability_rentalBookingId_idx" ON "RentalItemAvailability"("rentalBookingId");
|
||||||
|
|
||||||
|
-- RentalBooking
|
||||||
|
CREATE TABLE "RentalBooking" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"bookingId" TEXT,
|
||||||
|
"tenantId" TEXT NOT NULL,
|
||||||
|
"providerId" TEXT NOT NULL,
|
||||||
|
"startDate" TIMESTAMP(3) NOT NULL,
|
||||||
|
"endDate" TIMESTAMP(3) NOT NULL,
|
||||||
|
"status" "RentalBookingStatus" NOT NULL DEFAULT 'PENDING',
|
||||||
|
"paymentStatus" "PaymentStatus" NOT NULL DEFAULT 'PENDING',
|
||||||
|
"itemsTotal" DECIMAL(10,2) NOT NULL,
|
||||||
|
"depositTotal" DECIMAL(10,2) NOT NULL,
|
||||||
|
"commissionAmount" DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||||||
|
"amount" DECIMAL(10,2) NOT NULL,
|
||||||
|
"currency" TEXT NOT NULL DEFAULT 'EUR',
|
||||||
|
"stripeSessionId" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "RentalBooking_pkey" PRIMARY KEY ("id"),
|
||||||
|
CONSTRAINT "RentalBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE SET NULL ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "RentalBooking_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "RentalBooking_providerId_fkey" FOREIGN KEY ("providerId") REFERENCES "RentalProvider"("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX "RentalBooking_tenantId_status_idx" ON "RentalBooking"("tenantId", "status");
|
||||||
|
CREATE INDEX "RentalBooking_providerId_status_idx" ON "RentalBooking"("providerId", "status");
|
||||||
|
CREATE INDEX "RentalBooking_bookingId_idx" ON "RentalBooking"("bookingId");
|
||||||
|
CREATE INDEX "RentalBooking_startDate_endDate_idx" ON "RentalBooking"("startDate", "endDate");
|
||||||
|
|
||||||
|
-- RentalLine
|
||||||
|
CREATE TABLE "RentalLine" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"rentalBookingId" TEXT NOT NULL,
|
||||||
|
"itemId" TEXT NOT NULL,
|
||||||
|
"qty" INTEGER NOT NULL,
|
||||||
|
"pricePerDay" DECIMAL(8,2) NOT NULL,
|
||||||
|
"deposit" DECIMAL(8,2) NOT NULL DEFAULT 0,
|
||||||
|
"lineTotal" DECIMAL(10,2) NOT NULL,
|
||||||
|
CONSTRAINT "RentalLine_pkey" PRIMARY KEY ("id"),
|
||||||
|
CONSTRAINT "RentalLine_rentalBookingId_fkey" FOREIGN KEY ("rentalBookingId") REFERENCES "RentalBooking"("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "RentalLine_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "RentalItem"("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX "RentalLine_rentalBookingId_idx" ON "RentalLine"("rentalBookingId");
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
-- Sprint F : RentalItemMedia (photos & vidéos pour items rental).
|
||||||
|
-- Mêmes conventions que Media (carbet) : MediaType enum existant, s3Key/s3Url,
|
||||||
|
-- sortOrder pour cover (0). Cascade sur RentalItem.
|
||||||
|
|
||||||
|
CREATE TABLE "RentalItemMedia" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"itemId" TEXT NOT NULL,
|
||||||
|
"type" "MediaType" NOT NULL,
|
||||||
|
"s3Key" TEXT NOT NULL,
|
||||||
|
"s3Url" TEXT NOT NULL,
|
||||||
|
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "RentalItemMedia_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "RentalItemMedia_itemId_sortOrder_idx"
|
||||||
|
ON "RentalItemMedia"("itemId", "sortOrder");
|
||||||
|
|
||||||
|
ALTER TABLE "RentalItemMedia"
|
||||||
|
ADD CONSTRAINT "RentalItemMedia_itemId_fkey"
|
||||||
|
FOREIGN KEY ("itemId") REFERENCES "RentalItem"("id")
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
@ -13,6 +13,7 @@ enum UserRole {
|
||||||
CE_MEMBER
|
CE_MEMBER
|
||||||
TOURIST
|
TOURIST
|
||||||
ADMIN
|
ADMIN
|
||||||
|
RENTAL_PROVIDER
|
||||||
}
|
}
|
||||||
|
|
||||||
enum CarbetStatus {
|
enum CarbetStatus {
|
||||||
|
|
@ -59,6 +60,17 @@ enum SubscriptionStatus {
|
||||||
CANCELED
|
CANCELED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum AccessType {
|
||||||
|
ROAD_AND_RIVER
|
||||||
|
RIVER_ONLY
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TransportMode {
|
||||||
|
OWNER_PROVIDES
|
||||||
|
SELF_ARRANGE
|
||||||
|
PARTNER_PROVIDER
|
||||||
|
}
|
||||||
|
|
||||||
model Organization {
|
model Organization {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String
|
name String
|
||||||
|
|
@ -86,11 +98,13 @@ model User {
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
organization Organization? @relation(fields: [organizationId], references: [id], onDelete: SetNull)
|
organization Organization? @relation(fields: [organizationId], references: [id], onDelete: SetNull)
|
||||||
carbets Carbet[] @relation("CarbetOwner")
|
carbets Carbet[] @relation("CarbetOwner")
|
||||||
bookings Booking[] @relation("BookingTenant")
|
bookings Booking[] @relation("BookingTenant")
|
||||||
reviews Review[] @relation("ReviewAuthor")
|
reviews Review[] @relation("ReviewAuthor")
|
||||||
subscriptions Subscription[]
|
subscriptions Subscription[]
|
||||||
|
rentalProviders RentalProvider[]
|
||||||
|
rentalBookings RentalBooking[] @relation("RentalBookingTenant")
|
||||||
|
|
||||||
@@index([organizationId])
|
@@index([organizationId])
|
||||||
@@index([role])
|
@@index([role])
|
||||||
|
|
@ -106,24 +120,66 @@ model Carbet {
|
||||||
latitude Decimal @db.Decimal(9, 6)
|
latitude Decimal @db.Decimal(9, 6)
|
||||||
longitude Decimal @db.Decimal(9, 6)
|
longitude Decimal @db.Decimal(9, 6)
|
||||||
embarkPoint String
|
embarkPoint String
|
||||||
pirogueDurationMin Int
|
// 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?
|
||||||
capacity Int
|
capacity Int
|
||||||
|
// 4 critères opérationnels dealbreakers (dispo en filtres + badges UI)
|
||||||
|
roadAccess RoadAccess?
|
||||||
|
electricity Electricity?
|
||||||
|
gsmAtCarbet Boolean @default(false)
|
||||||
|
gsmExitDistanceKm Decimal? @db.Decimal(4, 2)
|
||||||
|
// Prix par nuit pour le carbet entier (toute capacité). En euros.
|
||||||
|
nightlyPrice Decimal @db.Decimal(10, 2) @default(0)
|
||||||
|
// Contraintes séjour (plugin min-stay). null = pas de contrainte.
|
||||||
|
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)
|
status CarbetStatus @default(DRAFT)
|
||||||
lastBookedAt DateTime?
|
lastBookedAt DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
owner User @relation("CarbetOwner", fields: [ownerId], references: [id], onDelete: Restrict)
|
owner User @relation("CarbetOwner", fields: [ownerId], references: [id], onDelete: Restrict)
|
||||||
amenities CarbetAmenity[]
|
pirogueProvider PirogueProvider? @relation(fields: [pirogueProviderId], references: [id], onDelete: SetNull)
|
||||||
media Media[]
|
amenities CarbetAmenity[]
|
||||||
availabilities Availability[]
|
media Media[]
|
||||||
bookings Booking[]
|
availabilities Availability[]
|
||||||
reviews Review[]
|
bookings Booking[]
|
||||||
|
reviews Review[]
|
||||||
subscriptions Subscription[]
|
subscriptions Subscription[]
|
||||||
|
|
||||||
@@index([ownerId])
|
@@index([ownerId])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
@@index([river])
|
@@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 {
|
model Amenity {
|
||||||
|
|
@ -196,7 +252,8 @@ model Booking {
|
||||||
carbet Carbet @relation(fields: [carbetId], references: [id], onDelete: Restrict)
|
carbet Carbet @relation(fields: [carbetId], references: [id], onDelete: Restrict)
|
||||||
tenant User @relation("BookingTenant", fields: [tenantId], references: [id], onDelete: Restrict)
|
tenant User @relation("BookingTenant", fields: [tenantId], references: [id], onDelete: Restrict)
|
||||||
|
|
||||||
review Review?
|
review Review?
|
||||||
|
rentalBookings RentalBooking[]
|
||||||
|
|
||||||
@@index([carbetId])
|
@@index([carbetId])
|
||||||
@@index([tenantId])
|
@@index([tenantId])
|
||||||
|
|
@ -244,3 +301,247 @@ model Review {
|
||||||
@@index([carbetId])
|
@@index([carbetId])
|
||||||
@@index([authorId])
|
@@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[]
|
||||||
|
media RentalItemMedia[]
|
||||||
|
|
||||||
|
@@index([providerId])
|
||||||
|
@@index([category, active])
|
||||||
|
}
|
||||||
|
|
||||||
|
model RentalItemMedia {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
itemId String
|
||||||
|
type MediaType
|
||||||
|
s3Key String
|
||||||
|
s3Url String
|
||||||
|
sortOrder Int @default(0)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
item RentalItem @relation(fields: [itemId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([itemId, sortOrder])
|
||||||
|
}
|
||||||
|
|
||||||
|
model RentalItemAvailability {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
itemId String
|
||||||
|
startDate DateTime
|
||||||
|
endDate DateTime
|
||||||
|
qty Int
|
||||||
|
reason String
|
||||||
|
rentalBookingId String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
item RentalItem @relation(fields: [itemId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([itemId, startDate, endDate])
|
||||||
|
@@index([rentalBookingId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model RentalBooking {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
bookingId String?
|
||||||
|
tenantId String
|
||||||
|
providerId String
|
||||||
|
startDate DateTime
|
||||||
|
endDate DateTime
|
||||||
|
status RentalBookingStatus @default(PENDING)
|
||||||
|
paymentStatus PaymentStatus @default(PENDING)
|
||||||
|
itemsTotal Decimal @db.Decimal(10, 2)
|
||||||
|
depositTotal Decimal @db.Decimal(10, 2)
|
||||||
|
commissionAmount Decimal @db.Decimal(10, 2) @default(0)
|
||||||
|
amount Decimal @db.Decimal(10, 2)
|
||||||
|
currency String @default("EUR")
|
||||||
|
stripeSessionId String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
booking Booking? @relation(fields: [bookingId], references: [id], onDelete: SetNull)
|
||||||
|
tenant User @relation("RentalBookingTenant", fields: [tenantId], references: [id], onDelete: Restrict)
|
||||||
|
provider RentalProvider @relation(fields: [providerId], references: [id], onDelete: Restrict)
|
||||||
|
lines RentalLine[]
|
||||||
|
|
||||||
|
@@index([tenantId, status])
|
||||||
|
@@index([providerId, status])
|
||||||
|
@@index([bookingId])
|
||||||
|
@@index([startDate, endDate])
|
||||||
|
}
|
||||||
|
|
||||||
|
model RentalLine {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
rentalBookingId String
|
||||||
|
itemId String
|
||||||
|
qty Int
|
||||||
|
pricePerDay Decimal @db.Decimal(8, 2)
|
||||||
|
deposit Decimal @db.Decimal(8, 2) @default(0)
|
||||||
|
lineTotal Decimal @db.Decimal(10, 2)
|
||||||
|
|
||||||
|
rentalBooking RentalBooking @relation(fields: [rentalBookingId], references: [id], onDelete: Cascade)
|
||||||
|
item RentalItem @relation(fields: [itemId], references: [id], onDelete: Restrict)
|
||||||
|
|
||||||
|
@@index([rentalBookingId])
|
||||||
|
}
|
||||||
|
|
|
||||||
BIN
public/icons/apple-touch-icon.png
Normal file
BIN
public/icons/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1 KiB |
BIN
public/icons/favicon-32.png
Normal file
BIN
public/icons/favicon-32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 208 B |
BIN
public/icons/icon-192-maskable.png
Normal file
BIN
public/icons/icon-192-maskable.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1 KiB |
BIN
public/icons/icon-192.png
Normal file
BIN
public/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
BIN
public/icons/icon-512-maskable.png
Normal file
BIN
public/icons/icon-512-maskable.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3 KiB |
BIN
public/icons/icon-512.png
Normal file
BIN
public/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.4 KiB |
60
public/manifest.webmanifest
Normal file
60
public/manifest.webmanifest
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
{
|
||||||
|
"name": "Karbé — carbets fluviaux de Guyane",
|
||||||
|
"short_name": "Karbé",
|
||||||
|
"description": "Au fil de l'eau : louez des carbets le long des fleuves de Guyane.",
|
||||||
|
"start_url": "/decouvrir",
|
||||||
|
"id": "/decouvrir",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"background_color": "#000000",
|
||||||
|
"theme_color": "#059669",
|
||||||
|
"lang": "fr",
|
||||||
|
"categories": ["travel", "lifestyle"],
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-192-maskable.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-512-maskable.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"shortcuts": [
|
||||||
|
{
|
||||||
|
"name": "Au fil de l'eau",
|
||||||
|
"short_name": "Découvrir",
|
||||||
|
"url": "/decouvrir",
|
||||||
|
"icons": [{ "src": "/icons/icon-192.png", "sizes": "192x192" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Mes favoris",
|
||||||
|
"short_name": "Favoris",
|
||||||
|
"url": "/mes-favoris",
|
||||||
|
"icons": [{ "src": "/icons/icon-192.png", "sizes": "192x192" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Mon compte",
|
||||||
|
"short_name": "Compte",
|
||||||
|
"url": "/mon-compte",
|
||||||
|
"icons": [{ "src": "/icons/icon-192.png", "sizes": "192x192" }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
54
scripts/backup-postgres.sh
Executable file
54
scripts/backup-postgres.sh
Executable file
|
|
@ -0,0 +1,54 @@
|
||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# Backup nightly du PostgreSQL Karbé vers MinIO.
|
||||||
|
# Lancé par un systemd timer (karbe-backup.timer).
|
||||||
|
#
|
||||||
|
# Rétention 30 jours côté MinIO (s'appuyer sur une lifecycle policy ou un
|
||||||
|
# nettoyage côté `mc rm` planifié — TODO si on veut être propre).
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
STAMP=$(date -u +%Y%m%d-%H%M%S)
|
||||||
|
DUMP_DIR=/tmp/karbe-backup
|
||||||
|
DUMP_FILE="$DUMP_DIR/karbe-${STAMP}.sql.gz"
|
||||||
|
BUCKET_DEST="karbe-backups/postgres/karbe-${STAMP}.sql.gz"
|
||||||
|
|
||||||
|
mkdir -p "$DUMP_DIR"
|
||||||
|
|
||||||
|
# Dump compressé depuis le conteneur postgres
|
||||||
|
docker compose -f /home/ubuntu/karbe/docker-compose.prod.yml \
|
||||||
|
-f /home/ubuntu/karbe/docker-compose.override.yml \
|
||||||
|
exec -T postgres pg_dump -U karbe -d karbe \
|
||||||
|
| gzip > "$DUMP_FILE"
|
||||||
|
|
||||||
|
SIZE=$(stat -c %s "$DUMP_FILE")
|
||||||
|
echo "[$(date -u +%FT%TZ)] dump created size=${SIZE}B path=${DUMP_FILE}"
|
||||||
|
|
||||||
|
# Push vers MinIO via mc Docker
|
||||||
|
docker run --rm --network karbe-net \
|
||||||
|
--entrypoint /bin/sh \
|
||||||
|
-v "$DUMP_DIR:/dump" \
|
||||||
|
-e MINIO_ROOT_USER \
|
||||||
|
-e MINIO_ROOT_PASSWORD \
|
||||||
|
minio/mc:latest -c "
|
||||||
|
mc alias set karbe http://minio:9000 \"\$MINIO_ROOT_USER\" \"\$MINIO_ROOT_PASSWORD\" >/dev/null 2>&1 && \
|
||||||
|
mc mb karbe/karbe-backups --ignore-existing >/dev/null 2>&1 && \
|
||||||
|
mc cp /dump/karbe-${STAMP}.sql.gz karbe/${BUCKET_DEST}
|
||||||
|
"
|
||||||
|
|
||||||
|
echo "[$(date -u +%FT%TZ)] uploaded to karbe/${BUCKET_DEST}"
|
||||||
|
|
||||||
|
# Nettoyage local
|
||||||
|
rm -f "$DUMP_FILE"
|
||||||
|
|
||||||
|
# Rétention : supprime les backups > 30 jours dans MinIO
|
||||||
|
docker run --rm --network karbe-net \
|
||||||
|
--entrypoint /bin/sh \
|
||||||
|
-e MINIO_ROOT_USER \
|
||||||
|
-e MINIO_ROOT_PASSWORD \
|
||||||
|
minio/mc:latest -c "
|
||||||
|
mc alias set karbe http://minio:9000 \"\$MINIO_ROOT_USER\" \"\$MINIO_ROOT_PASSWORD\" >/dev/null 2>&1 && \
|
||||||
|
mc rm --recursive --force --older-than 30d karbe/karbe-backups/ 2>/dev/null || true
|
||||||
|
"
|
||||||
|
|
||||||
|
echo "[$(date -u +%FT%TZ)] retention sweep done (>30d removed)"
|
||||||
35
scripts/upload-aquarelles.sh
Executable file
35
scripts/upload-aquarelles.sh
Executable file
|
|
@ -0,0 +1,35 @@
|
||||||
|
#!/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
|
||||||
|
"
|
||||||
19
src/app/a-propos/page.tsx
Normal file
19
src/app/a-propos/page.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
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} />;
|
||||||
|
}
|
||||||
60
src/app/accueil/page.tsx
Normal file
60
src/app/accueil/page.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
import Link from "next/link";
|
||||||
|
import { IfPluginEnabled } from "@/components/IfPluginEnabled";
|
||||||
|
import { HeroSection } from "@/components/landing/HeroSection";
|
||||||
|
import { ExperiencesSection } from "@/components/landing/ExperiencesSection";
|
||||||
|
import { HowItWorksSection } from "@/components/landing/HowItWorksSection";
|
||||||
|
import { CESection } from "@/components/landing/CESection";
|
||||||
|
import { TestimonialsSection } from "@/components/landing/TestimonialsSection";
|
||||||
|
import { LandingFooter } from "@/components/landing/Footer";
|
||||||
|
|
||||||
|
export const metadata = { title: "Accueil — Karbé" };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Landing « marketing » historique (hero + sections + footer riche). Conservée
|
||||||
|
* à /accueil après la promotion de /decouvrir comme nouvelle page d'index.
|
||||||
|
*/
|
||||||
|
export default function LandingPage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
134
src/app/admin/audit/page.tsx
Normal file
134
src/app/admin/audit/page.tsx
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
156
src/app/admin/bookings/[id]/_components/BookingActions.tsx
Normal file
156
src/app/admin/bookings/[id]/_components/BookingActions.tsx
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
121
src/app/admin/bookings/[id]/page.tsx
Normal file
121
src/app/admin/bookings/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
108
src/app/admin/bookings/actions.ts
Normal file
108
src/app/admin/bookings/actions.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
"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 };
|
||||||
|
}
|
||||||
184
src/app/admin/bookings/page.tsx
Normal file
184
src/app/admin/bookings/page.tsx
Normal file
|
|
@ -0,0 +1,184 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
141
src/app/admin/carbets/[id]/_components/MediaManager.tsx
Normal file
141
src/app/admin/carbets/[id]/_components/MediaManager.tsx
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
93
src/app/admin/carbets/[id]/_components/StatusActions.tsx
Normal file
93
src/app/admin/carbets/[id]/_components/StatusActions.tsx
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
113
src/app/admin/carbets/[id]/page.tsx
Normal file
113
src/app/admin/carbets/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
342
src/app/admin/carbets/_components/CarbetForm.tsx
Normal file
342
src/app/admin/carbets/_components/CarbetForm.tsx
Normal file
|
|
@ -0,0 +1,342 @@
|
||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
229
src/app/admin/carbets/actions.ts
Normal file
229
src/app/admin/carbets/actions.ts
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
"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,
|
||||||
|
});
|
||||||
|
}
|
||||||
20
src/app/admin/carbets/new/page.tsx
Normal file
20
src/app/admin/carbets/new/page.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
148
src/app/admin/carbets/page.tsx
Normal file
148
src/app/admin/carbets/page.tsx
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,97 @@
|
||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
94
src/app/admin/content-pages/[slug]/page.tsx
Normal file
94
src/app/admin/content-pages/[slug]/page.tsx
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
158
src/app/admin/content-pages/page.tsx
Normal file
158
src/app/admin/content-pages/page.tsx
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
169
src/app/admin/home/_components/HomeTranslationsForm.tsx
Normal file
169
src/app/admin/home/_components/HomeTranslationsForm.tsx
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
src/app/admin/home/actions.ts
Normal file
67
src/app/admin/home/actions.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
"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 };
|
||||||
|
}
|
||||||
39
src/app/admin/home/page.tsx
Normal file
39
src/app/admin/home/page.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
src/app/admin/layout.tsx
Normal file
24
src/app/admin/layout.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
137
src/app/admin/media/page.tsx
Normal file
137
src/app/admin/media/page.tsx
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
90
src/app/admin/organizations/[id]/page.tsx
Normal file
90
src/app/admin/organizations/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
77
src/app/admin/organizations/_components/OrgForm.tsx
Normal file
77
src/app/admin/organizations/_components/OrgForm.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
89
src/app/admin/organizations/actions.ts
Normal file
89
src/app/admin/organizations/actions.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
"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");
|
||||||
|
}
|
||||||
21
src/app/admin/organizations/new/page.tsx
Normal file
21
src/app/admin/organizations/new/page.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
89
src/app/admin/organizations/page.tsx
Normal file
89
src/app/admin/organizations/page.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
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,14 +1,103 @@
|
||||||
import { requireRole } from "@/lib/authorization";
|
import Link from "next/link";
|
||||||
|
import { formatEur, getAdminKpis } from "@/lib/admin/kpis";
|
||||||
|
import { KPICard } from "@/components/admin/KPICard";
|
||||||
|
|
||||||
export default async function AdminPage() {
|
export const dynamic = "force-dynamic";
|
||||||
const session = await requireRole(["ADMIN"]);
|
|
||||||
|
export default async function AdminDashboard() {
|
||||||
|
const kpis = await getAdminKpis();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto max-w-4xl px-6 py-12">
|
<div className="mx-auto max-w-6xl">
|
||||||
<h1 className="text-3xl font-semibold">Espace administrateur</h1>
|
<header className="mb-6 mt-2">
|
||||||
<p className="mt-4 text-zinc-700">
|
<h1 className="text-2xl font-semibold text-zinc-900">Tableau de bord</h1>
|
||||||
Accès autorisé pour {session.user.email} ({session.user.role}).
|
<p className="mt-1 text-sm text-zinc-500">
|
||||||
</p>
|
Vue d'ensemble de l'activité Karbé. Données live (cache 0).
|
||||||
</main>
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
105
src/app/admin/pirogue-providers/[id]/page.tsx
Normal file
105
src/app/admin/pirogue-providers/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
119
src/app/admin/pirogue-providers/_components/ProviderForm.tsx
Normal file
119
src/app/admin/pirogue-providers/_components/ProviderForm.tsx
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
95
src/app/admin/pirogue-providers/actions.ts
Normal file
95
src/app/admin/pirogue-providers/actions.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
"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");
|
||||||
|
}
|
||||||
21
src/app/admin/pirogue-providers/new/page.tsx
Normal file
21
src/app/admin/pirogue-providers/new/page.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
124
src/app/admin/pirogue-providers/page.tsx
Normal file
124
src/app/admin/pirogue-providers/page.tsx
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
102
src/app/admin/plugins/_components/PluginToggleTable.tsx
Normal file
102
src/app/admin/plugins/_components/PluginToggleTable.tsx
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
src/app/admin/plugins/page.tsx
Normal file
26
src/app/admin/plugins/page.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
active: boolean;
|
||||||
|
toggleActiveAction: (active: boolean) => Promise<{ ok: true } | { ok: false; error: string } | undefined>;
|
||||||
|
deleteAction: () => Promise<{ ok: true } | { ok: false; error: string } | undefined | void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ItemInlineActions({ active, toggleActiveAction, deleteAction }: Props) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
|
const [error, setError] = useState<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
92
src/app/admin/rental-items/[id]/page.tsx
Normal file
92
src/app/admin/rental-items/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { StatusBadge } from "@/components/admin/StatusBadge";
|
||||||
|
import { MediaUploader } from "@/components/MediaUploader";
|
||||||
|
import { getRentalItemForAdmin, listProvidersForSelect, RENTAL_CATEGORY_LABEL } from "@/lib/admin/rental-items";
|
||||||
|
|
||||||
|
import { ItemForm } from "../_components/ItemForm";
|
||||||
|
import { ItemInlineActions } from "./_components/ItemInlineActions";
|
||||||
|
import { deleteRentalItemAction, toggleRentalItemActiveAction, updateRentalItemAction } from "../actions";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
type PageProps = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
export default async function EditRentalItemPage({ params }: PageProps) {
|
||||||
|
const { id } = await params;
|
||||||
|
const [item, providers] = await Promise.all([getRentalItemForAdmin(id), listProvidersForSelect()]);
|
||||||
|
if (!item) notFound();
|
||||||
|
|
||||||
|
const updateThis = async (fd: FormData) => {
|
||||||
|
"use server";
|
||||||
|
return await updateRentalItemAction(id, fd);
|
||||||
|
};
|
||||||
|
const toggleActiveThis = async (active: boolean) => {
|
||||||
|
"use server";
|
||||||
|
return await toggleRentalItemActiveAction(id, active);
|
||||||
|
};
|
||||||
|
const deleteThis = async () => {
|
||||||
|
"use server";
|
||||||
|
return await deleteRentalItemAction(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-4xl space-y-6">
|
||||||
|
<header className="mt-2 flex flex-wrap items-end justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<Link href="/admin/rental-items" className="text-xs text-zinc-500 hover:text-zinc-900">
|
||||||
|
← Tous les items
|
||||||
|
</Link>
|
||||||
|
<h1 className="mt-1 flex items-center gap-3 text-2xl font-semibold text-zinc-900">
|
||||||
|
{item.name}
|
||||||
|
<StatusBadge status={item.active ? "ACTIVE" : "INACTIVE"} />
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-zinc-500">
|
||||||
|
{RENTAL_CATEGORY_LABEL[item.category]} ·{" "}
|
||||||
|
<Link href={`/admin/rental-providers/${item.provider.id}`} className="text-zinc-900 hover:underline">
|
||||||
|
{item.provider.name}
|
||||||
|
</Link>
|
||||||
|
{item.provider.isSystemD ? " (System D)" : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ItemInlineActions
|
||||||
|
active={item.active}
|
||||||
|
toggleActiveAction={toggleActiveThis}
|
||||||
|
deleteAction={deleteThis}
|
||||||
|
/>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
||||||
|
<h2 className="mb-3 text-base font-semibold text-zinc-900">Photos & vidéos</h2>
|
||||||
|
<MediaUploader
|
||||||
|
scope={{ kind: "rental-item", itemId: item.id }}
|
||||||
|
initialMedia={item.media}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
||||||
|
<ItemForm
|
||||||
|
providers={providers}
|
||||||
|
action={updateThis}
|
||||||
|
submitLabel="Enregistrer les modifications"
|
||||||
|
initial={{
|
||||||
|
providerId: item.providerId,
|
||||||
|
category: item.category,
|
||||||
|
name: item.name,
|
||||||
|
description: item.description,
|
||||||
|
imageUrl: item.imageUrl,
|
||||||
|
pricePerDay: item.pricePerDay.toString(),
|
||||||
|
pricePerWeek: item.pricePerWeek?.toString() ?? null,
|
||||||
|
deposit: item.deposit.toString(),
|
||||||
|
totalQty: item.totalQty,
|
||||||
|
withMotor: item.withMotor,
|
||||||
|
fuelIncluded: item.fuelIncluded,
|
||||||
|
requiresLicense: item.requiresLicense,
|
||||||
|
active: item.active,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
132
src/app/admin/rental-items/_components/ItemForm.tsx
Normal file
132
src/app/admin/rental-items/_components/ItemForm.tsx
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { FormField, inputCls, selectCls, textareaCls } from "@/components/admin/FormField";
|
||||||
|
import { RENTAL_CATEGORY_LABEL, RENTAL_CATEGORIES } from "@/lib/rental-category-labels";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
providers: { id: string; name: string; isSystemD: boolean }[];
|
||||||
|
initial?: {
|
||||||
|
providerId?: string;
|
||||||
|
category?: string;
|
||||||
|
name?: string;
|
||||||
|
description?: string | null;
|
||||||
|
imageUrl?: string | null;
|
||||||
|
pricePerDay?: string | number;
|
||||||
|
pricePerWeek?: string | number | null;
|
||||||
|
deposit?: string | number;
|
||||||
|
totalQty?: number;
|
||||||
|
withMotor?: boolean;
|
||||||
|
fuelIncluded?: boolean;
|
||||||
|
requiresLicense?: boolean;
|
||||||
|
active?: boolean;
|
||||||
|
};
|
||||||
|
action: (fd: FormData) => Promise<{ ok: false; error: string } | { ok: true } | undefined>;
|
||||||
|
submitLabel?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ItemForm({ providers, initial = {}, action, submitLabel = "Enregistrer" }: Props) {
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
const [error, setError] = useState<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
129
src/app/admin/rental-items/actions.ts
Normal file
129
src/app/admin/rental-items/actions.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
"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");
|
||||||
|
}
|
||||||
31
src/app/admin/rental-items/new/page.tsx
Normal file
31
src/app/admin/rental-items/new/page.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
152
src/app/admin/rental-items/page.tsx
Normal file
152
src/app/admin/rental-items/page.tsx
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,120 @@
|
||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
136
src/app/admin/rental-providers/[id]/page.tsx
Normal file
136
src/app/admin/rental-providers/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
132
src/app/admin/rental-providers/_components/ProviderForm.tsx
Normal file
132
src/app/admin/rental-providers/_components/ProviderForm.tsx
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
150
src/app/admin/rental-providers/actions.ts
Normal file
150
src/app/admin/rental-providers/actions.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
"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");
|
||||||
|
}
|
||||||
21
src/app/admin/rental-providers/new/page.tsx
Normal file
21
src/app/admin/rental-providers/new/page.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
149
src/app/admin/rental-providers/page.tsx
Normal file
149
src/app/admin/rental-providers/page.tsx
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
141
src/app/admin/rentals/page.tsx
Normal file
141
src/app/admin/rentals/page.tsx
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
134
src/app/admin/reviews/[id]/_components/ReviewForm.tsx
Normal file
134
src/app/admin/reviews/[id]/_components/ReviewForm.tsx
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
src/app/admin/reviews/[id]/page.tsx
Normal file
52
src/app/admin/reviews/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
src/app/admin/reviews/actions.ts
Normal file
60
src/app/admin/reviews/actions.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
"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 };
|
||||||
|
}
|
||||||
134
src/app/admin/reviews/page.tsx
Normal file
134
src/app/admin/reviews/page.tsx
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
171
src/app/admin/settings/_components/SettingsForms.tsx
Normal file
171
src/app/admin/settings/_components/SettingsForms.tsx
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
100
src/app/admin/settings/actions.ts
Normal file
100
src/app/admin/settings/actions.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
"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 };
|
||||||
|
}
|
||||||
100
src/app/admin/settings/page.tsx
Normal file
100
src/app/admin/settings/page.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
120
src/app/admin/users/[id]/_components/UserActions.tsx
Normal file
120
src/app/admin/users/[id]/_components/UserActions.tsx
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
133
src/app/admin/users/[id]/page.tsx
Normal file
133
src/app/admin/users/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
src/app/admin/users/actions.ts
Normal file
59
src/app/admin/users/actions.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
"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 };
|
||||||
|
}
|
||||||
136
src/app/admin/users/page.tsx
Normal file
136
src/app/admin/users/page.tsx
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
src/app/api/admin/carbets/[id]/media/route.ts
Normal file
17
src/app/api/admin/carbets/[id]/media/route.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
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);
|
||||||
|
}
|
||||||
50
src/app/api/admin/content-pages/[slug]/route.ts
Normal file
50
src/app/api/admin/content-pages/[slug]/route.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
39
src/app/api/admin/plugins/[key]/route.ts
Normal file
39
src/app/api/admin/plugins/[key]/route.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
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);
|
||||||
|
}
|
||||||
14
src/app/api/admin/search/route.ts
Normal file
14
src/app/api/admin/search/route.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
|
@ -16,6 +16,8 @@ import {
|
||||||
parseIsoDate,
|
parseIsoDate,
|
||||||
} from "@/lib/booking";
|
} from "@/lib/booking";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { sendBookingRequestToOwner, sendBookingRequestToTenant } from "@/lib/email";
|
||||||
|
import { rateLimitRequest } from "@/lib/rate-limit";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
|
@ -27,6 +29,14 @@ type CreateBookingBody = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
|
const rl = rateLimitRequest(request, "bookings", 60 * 60 * 1000, 10);
|
||||||
|
if (!rl.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Trop de tentatives. Réessayez dans ${rl.retryAfter}s.` },
|
||||||
|
{ status: 429, headers: { "Retry-After": String(rl.retryAfter) } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return NextResponse.json({ error: "Non authentifié." }, { status: 401 });
|
return NextResponse.json({ error: "Non authentifié." }, { status: 401 });
|
||||||
|
|
@ -78,6 +88,9 @@ export async function POST(request: Request) {
|
||||||
ownerId: true,
|
ownerId: true,
|
||||||
capacity: true,
|
capacity: true,
|
||||||
status: true,
|
status: true,
|
||||||
|
nightlyPrice: true,
|
||||||
|
title: true,
|
||||||
|
owner: { select: { email: true, firstName: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -183,6 +196,12 @@ export async function POST(request: Request) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nights = Math.max(
|
||||||
|
1,
|
||||||
|
Math.round((endDate.getTime() - startDate.getTime()) / 86400000),
|
||||||
|
);
|
||||||
|
const computedAmount = Number(carbet.nightlyPrice) * nights;
|
||||||
|
|
||||||
const booking = await prisma.booking.create({
|
const booking = await prisma.booking.create({
|
||||||
data: {
|
data: {
|
||||||
carbetId: carbet.id,
|
carbetId: carbet.id,
|
||||||
|
|
@ -191,7 +210,7 @@ export async function POST(request: Request) {
|
||||||
endDate,
|
endDate,
|
||||||
guestCount,
|
guestCount,
|
||||||
status: BookingStatus.PENDING,
|
status: BookingStatus.PENDING,
|
||||||
amount: 0,
|
amount: computedAmount.toFixed(2),
|
||||||
currency: "EUR",
|
currency: "EUR",
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
|
|
@ -207,5 +226,34 @@ export async function POST(request: Request) {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Best-effort emails (n'échouent pas la réservation si Resend down).
|
||||||
|
const tenant = await prisma.user.findUnique({
|
||||||
|
where: { id: session.user.id },
|
||||||
|
select: { email: true, firstName: true, lastName: true },
|
||||||
|
});
|
||||||
|
if (tenant) {
|
||||||
|
sendBookingRequestToTenant(
|
||||||
|
tenant.email,
|
||||||
|
tenant.firstName,
|
||||||
|
booking.id,
|
||||||
|
carbet.title,
|
||||||
|
booking.startDate,
|
||||||
|
booking.endDate,
|
||||||
|
computedAmount.toFixed(2),
|
||||||
|
"EUR",
|
||||||
|
).catch(() => {});
|
||||||
|
}
|
||||||
|
if (carbet.owner?.email && tenant) {
|
||||||
|
sendBookingRequestToOwner(
|
||||||
|
carbet.owner.email,
|
||||||
|
carbet.owner.firstName,
|
||||||
|
booking.id,
|
||||||
|
carbet.title,
|
||||||
|
`${tenant.firstName} ${tenant.lastName}`.trim(),
|
||||||
|
booking.startDate,
|
||||||
|
booking.endDate,
|
||||||
|
).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({ booking }, { status: 201 });
|
return NextResponse.json({ booking }, { status: 201 });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
37
src/app/api/cron/run/[task]/route.ts
Normal file
37
src/app/api/cron/run/[task]/route.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { SCHEDULED_TASKS, type ScheduledTaskName } from "@/lib/scheduled";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
function authorized(req: Request): boolean {
|
||||||
|
const secret = (process.env.CRON_TOKEN ?? "").trim();
|
||||||
|
if (!secret) return false;
|
||||||
|
const header = req.headers.get("authorization") ?? "";
|
||||||
|
const token = header.startsWith("Bearer ") ? header.slice(7) : "";
|
||||||
|
return token === secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: Request, ctx: { params: Promise<{ task: string }> }) {
|
||||||
|
if (!authorized(req)) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
const { task } = await ctx.params;
|
||||||
|
const fn = SCHEDULED_TASKS[task as ScheduledTaskName];
|
||||||
|
if (!fn) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Unknown task. Available: ${Object.keys(SCHEDULED_TASKS).join(", ")}` },
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await fn();
|
||||||
|
return NextResponse.json({ ok: true, task, result });
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: e instanceof Error ? e.message : String(e) },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
61
src/app/api/favorites/route.ts
Normal file
61
src/app/api/favorites/route.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
carbetId: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
async function requireSelf() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) throw new Error("Unauth");
|
||||||
|
return session.user.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const userId = await requireSelf();
|
||||||
|
const rows = await prisma.favorite.findMany({
|
||||||
|
where: { userId },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
select: { carbetId: true },
|
||||||
|
});
|
||||||
|
return NextResponse.json({ ids: rows.map((r) => r.carbetId) });
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ ids: [] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const userId = await requireSelf();
|
||||||
|
const parsed = schema.safeParse(await req.json().catch(() => ({})));
|
||||||
|
if (!parsed.success) return NextResponse.json({ error: "Payload invalide" }, { status: 400 });
|
||||||
|
await prisma.favorite.upsert({
|
||||||
|
where: { userId_carbetId: { userId, carbetId: parsed.data.carbetId } },
|
||||||
|
create: { userId, carbetId: parsed.data.carbetId },
|
||||||
|
update: {},
|
||||||
|
});
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(req: Request) {
|
||||||
|
try {
|
||||||
|
const userId = await requireSelf();
|
||||||
|
const parsed = schema.safeParse(await req.json().catch(() => ({})));
|
||||||
|
if (!parsed.success) return NextResponse.json({ error: "Payload invalide" }, { status: 400 });
|
||||||
|
await prisma.favorite
|
||||||
|
.delete({ where: { userId_carbetId: { userId, carbetId: parsed.data.carbetId } } })
|
||||||
|
.catch(() => null);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,101 @@
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import { S3Client, HeadBucketCommand } from "@aws-sdk/client-s3";
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
type Probe = {
|
||||||
|
name: string;
|
||||||
|
ok: boolean;
|
||||||
|
latencyMs: number;
|
||||||
|
details?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function probeDb(): Promise<Probe> {
|
||||||
|
const t0 = Date.now();
|
||||||
|
try {
|
||||||
|
await prisma.$queryRaw`SELECT 1 AS ok`;
|
||||||
|
return { name: "database", ok: true, latencyMs: Date.now() - t0 };
|
||||||
|
} catch (e) {
|
||||||
|
return {
|
||||||
|
name: "database",
|
||||||
|
ok: false,
|
||||||
|
latencyMs: Date.now() - t0,
|
||||||
|
details: e instanceof Error ? e.message : String(e),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probeS3(): Promise<Probe> {
|
||||||
|
const t0 = Date.now();
|
||||||
|
const bucket = process.env.S3_BUCKET;
|
||||||
|
const endpoint = process.env.S3_ENDPOINT;
|
||||||
|
if (!bucket || !endpoint) {
|
||||||
|
return { name: "s3", ok: false, latencyMs: 0, details: "S3_BUCKET ou S3_ENDPOINT manquant" };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const client = new S3Client({
|
||||||
|
endpoint,
|
||||||
|
region: process.env.S3_REGION ?? "us-east-1",
|
||||||
|
forcePathStyle: (process.env.S3_FORCE_PATH_STYLE ?? "false") === "true",
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: process.env.MINIO_ROOT_USER ?? process.env.S3_ACCESS_KEY ?? "",
|
||||||
|
secretAccessKey: process.env.MINIO_ROOT_PASSWORD ?? process.env.S3_SECRET_KEY ?? "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await client.send(new HeadBucketCommand({ Bucket: bucket }));
|
||||||
|
return { name: "s3", ok: true, latencyMs: Date.now() - t0 };
|
||||||
|
} catch (e) {
|
||||||
|
return {
|
||||||
|
name: "s3",
|
||||||
|
ok: false,
|
||||||
|
latencyMs: Date.now() - t0,
|
||||||
|
details: e instanceof Error ? e.message : String(e),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function probeResend(): Probe {
|
||||||
|
return {
|
||||||
|
name: "resend",
|
||||||
|
ok: Boolean(process.env.RESEND_API_KEY?.trim()),
|
||||||
|
latencyMs: 0,
|
||||||
|
details: process.env.RESEND_API_KEY ? undefined : "RESEND_API_KEY non configuré (dry-run)",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function probeStripe(): Probe {
|
||||||
|
const key = (process.env.STRIPE_SECRET_KEY ?? "").trim();
|
||||||
|
const configured = key.length > 0 && !key.includes("REPLACE_ME");
|
||||||
|
return {
|
||||||
|
name: "stripe",
|
||||||
|
ok: configured,
|
||||||
|
latencyMs: 0,
|
||||||
|
details: configured ? undefined : "STRIPE_SECRET_KEY non configuré",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
return NextResponse.json({ status: "ok" });
|
const t0 = Date.now();
|
||||||
|
const [db, s3] = await Promise.all([probeDb(), probeS3()]);
|
||||||
|
const resend = probeResend();
|
||||||
|
const stripe = probeStripe();
|
||||||
|
const probes = [db, s3, resend, stripe];
|
||||||
|
|
||||||
|
// DB est critique (503 si down). Le reste = non bloquant.
|
||||||
|
const critical = db.ok;
|
||||||
|
const status = critical ? 200 : 503;
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
status: critical ? "ok" : "degraded",
|
||||||
|
version: process.env.DEPLOYMENT_VERSION ?? "unknown",
|
||||||
|
uptimeSeconds: Math.round(process.uptime()),
|
||||||
|
latencyMs: Date.now() - t0,
|
||||||
|
probes,
|
||||||
|
},
|
||||||
|
{ status },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
103
src/app/api/me/export/route.ts
Normal file
103
src/app/api/me/export/route.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { recordAudit } from "@/lib/admin/audit";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/** RGPD article 20 — droit à la portabilité. Renvoie un JSON avec toutes les données utilisateur. */
|
||||||
|
export async function GET() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
|
||||||
|
}
|
||||||
|
const userId = session.user.id;
|
||||||
|
|
||||||
|
const [user, bookings, reviews, carbets, subscriptions] = await Promise.all([
|
||||||
|
prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
phone: true,
|
||||||
|
role: true,
|
||||||
|
avatarUrl: true,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
organizationId: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.booking.findMany({
|
||||||
|
where: { tenantId: userId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
carbetId: true,
|
||||||
|
startDate: true,
|
||||||
|
endDate: true,
|
||||||
|
guestCount: true,
|
||||||
|
status: true,
|
||||||
|
paymentStatus: true,
|
||||||
|
amount: true,
|
||||||
|
currency: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.review.findMany({
|
||||||
|
where: { authorId: userId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
bookingId: true,
|
||||||
|
carbetId: true,
|
||||||
|
rating: true,
|
||||||
|
comment: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.carbet.findMany({
|
||||||
|
where: { ownerId: userId },
|
||||||
|
select: { id: true, slug: true, title: true, status: true, createdAt: true },
|
||||||
|
}),
|
||||||
|
prisma.subscription.findMany({
|
||||||
|
where: { ownerId: userId },
|
||||||
|
select: { id: true, carbetId: true, status: true, provider: true, startedAt: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await recordAudit({
|
||||||
|
scope: "public.profile",
|
||||||
|
event: "data.export",
|
||||||
|
target: userId,
|
||||||
|
actorEmail: session.user.email ?? null,
|
||||||
|
details: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const filename = `karbe-mes-donnees-${new Date().toISOString().slice(0, 10)}.json`;
|
||||||
|
return new NextResponse(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
rgpdNotice:
|
||||||
|
"Conformément à l'article 20 du RGPD. Pour exercer vos autres droits, contactez contact@karbe.cosmolan.fr.",
|
||||||
|
user,
|
||||||
|
bookings,
|
||||||
|
reviews,
|
||||||
|
carbets,
|
||||||
|
subscriptions,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json; charset=utf-8",
|
||||||
|
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
41
src/app/api/media/[id]/route.ts
Normal file
41
src/app/api/media/[id]/route.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { UserRole } from "@/generated/prisma/enums";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { recordAudit } from "@/lib/admin/audit";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
async function requireOwnership(mediaId: string) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) throw new Error("Non authentifié");
|
||||||
|
const m = await prisma.media.findUnique({
|
||||||
|
where: { id: mediaId },
|
||||||
|
select: { id: true, carbetId: true, carbet: { select: { ownerId: true } } },
|
||||||
|
});
|
||||||
|
if (!m) throw new Error("Média introuvable");
|
||||||
|
const isAdmin = session.user.role === UserRole.ADMIN;
|
||||||
|
if (!isAdmin && m.carbet.ownerId !== session.user.id) throw new Error("Accès refusé");
|
||||||
|
return { session, media: m };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(_req: Request, ctx: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await ctx.params;
|
||||||
|
try {
|
||||||
|
const { session, media } = await requireOwnership(id);
|
||||||
|
await prisma.media.delete({ where: { id } });
|
||||||
|
await recordAudit({
|
||||||
|
scope: "uploads",
|
||||||
|
event: "media.delete",
|
||||||
|
target: id,
|
||||||
|
actorEmail: session.user.email ?? null,
|
||||||
|
details: { carbetId: media.carbetId },
|
||||||
|
});
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
const status = msg === "Non authentifié" ? 401 : msg === "Accès refusé" ? 403 : 404;
|
||||||
|
return NextResponse.json({ error: msg }, { status });
|
||||||
|
}
|
||||||
|
}
|
||||||
55
src/app/api/media/reorder/route.ts
Normal file
55
src/app/api/media/reorder/route.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { UserRole } from "@/generated/prisma/enums";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { recordAudit } from "@/lib/admin/audit";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
carbetId: z.string().min(1),
|
||||||
|
orderedIds: z.array(z.string()).min(1).max(50),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
|
||||||
|
}
|
||||||
|
const parsed = schema.safeParse(await req.json().catch(() => ({})));
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json({ error: "Payload invalide" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const { carbetId, orderedIds } = parsed.data;
|
||||||
|
const carbet = await prisma.carbet.findUnique({
|
||||||
|
where: { id: carbetId },
|
||||||
|
select: { ownerId: true },
|
||||||
|
});
|
||||||
|
if (!carbet) return NextResponse.json({ error: "Carbet introuvable" }, { status: 404 });
|
||||||
|
const isAdmin = session.user.role === UserRole.ADMIN;
|
||||||
|
if (!isAdmin && carbet.ownerId !== session.user.id) {
|
||||||
|
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
|
||||||
|
}
|
||||||
|
const existing = await prisma.media.findMany({
|
||||||
|
where: { carbetId, id: { in: orderedIds } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (existing.length !== orderedIds.length) {
|
||||||
|
return NextResponse.json({ error: "Certains médias n'appartiennent pas au carbet." }, { status: 400 });
|
||||||
|
}
|
||||||
|
await prisma.$transaction(
|
||||||
|
orderedIds.map((id, idx) =>
|
||||||
|
prisma.media.update({ where: { id }, data: { sortOrder: idx } }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await recordAudit({
|
||||||
|
scope: "uploads",
|
||||||
|
event: "media.reorder",
|
||||||
|
target: carbetId,
|
||||||
|
actorEmail: session.user.email ?? null,
|
||||||
|
details: { count: orderedIds.length },
|
||||||
|
});
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
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