Merge pull request #7 — socle applicatif et implémentation du design

Socle applicatif et implémentation du design
This commit is contained in:
LogiFlow authored and GitHub committed 2026-08-08 00:27:15 +02:00
commit 04ba7d5f87
67 files changed
+11099 -1

No files matched your search

+25
View File
@@ -0,0 +1,25 @@
# Copier vers .env et renseigner. Ne jamais committer .env.
# --- Base de données --------------------------------------------------------
POSTGRES_USER=planflow
POSTGRES_PASSWORD=change-me
POSTGRES_DB=planflow
# Utilisée par l'application et par Prisma.
# En docker-compose l'hôte est `db` ; en développement local, `localhost`.
DATABASE_URL=postgresql://planflow:change-me@localhost:5432/planflow
# --- Chiffrement ------------------------------------------------------------
# Chiffre au repos les colonnes sensibles exigées par PLAN.md §3.6 :
# NIR, IBAN, BIC. 32 octets en base64.
#
# openssl rand -base64 32
#
# Cette clé vit hors de la base : une sauvegarde volée ne doit pas suffire à
# lire ces colonnes. La perdre rend les données chiffrées irrécupérables —
# la sauvegarder séparément et documenter sa rotation.
ENCRYPTION_KEY=
# --- Application ------------------------------------------------------------
APP_URL=http://localhost:3000
APP_PORT=3000
+71
View File
@@ -0,0 +1,71 @@
name: CI
on:
push:
branches: [main]
pull_request:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
# Test-only values. Real secrets never live in CI configuration.
DATABASE_URL: postgresql://planflow:planflow@localhost:5432/planflow_test
ENCRYPTION_KEY: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
APP_URL: http://127.0.0.1:3100
jobs:
verify:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: planflow
POSTGRES_PASSWORD: planflow
POSTGRES_DB: planflow_test
ports: ['5432:5432']
options: >-
--health-cmd "pg_isready -U planflow"
--health-interval 5s
--health-timeout 5s
--health-retries 10
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm db:generate
- name: Appliquer les migrations
run: pnpm db:deploy
- run: pnpm typecheck
- run: pnpm lint
- run: pnpm test
- run: pnpm build
- name: Installer le navigateur Playwright
run: pnpm exec playwright install --with-deps chromium
- run: pnpm test:e2e
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/
retention-days: 7
+23
View File
@@ -0,0 +1,23 @@
node_modules/
.pnpm-store/
.next/
out/
build/
next-env.d.ts
*.tsbuildinfo
.env
.env.*
!.env.example
coverage/
test-results/
playwright-report/
blob-report/
.playwright/
.DS_Store
*.log
.tmp/
.pgdata/
+42
View File
@@ -0,0 +1,42 @@
# syntax=docker/dockerfile:1
FROM node:22-alpine AS base
RUN corepack enable
WORKDIR /app
# ---- dependencies -----------------------------------------------------------
FROM base AS deps
COPY package.json pnpm-lock.yaml ./
COPY prisma ./prisma
RUN pnpm install --frozen-lockfile
# ---- build ------------------------------------------------------------------
FROM base AS build
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN pnpm db:generate && pnpm build
# ---- runtime ----------------------------------------------------------------
FROM base AS runner
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 --ingroup nodejs nextjs
# `output: standalone` emits a server bundle carrying only the modules it uses.
COPY --from=build --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=build --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=build --chown=nextjs:nodejs /app/public ./public
# Migrations run at startup, so the image can be deployed without a separate
# migration step. prisma/ and its CLI are needed for that.
COPY --from=build --chown=nextjs:nodejs /app/prisma ./prisma
COPY --from=build --chown=nextjs:nodejs /app/node_modules/prisma ./node_modules/prisma
COPY --from=build --chown=nextjs:nodejs /app/node_modules/.bin/prisma ./node_modules/.bin/prisma
COPY --from=build --chown=nextjs:nodejs /app/node_modules/@prisma ./node_modules/@prisma
USER nextjs
EXPOSE 3000
ENV PORT=3000 HOSTNAME=0.0.0.0
CMD ["sh", "-c", "./node_modules/.bin/prisma migrate deploy && node server.js"]
+13 -1
View File
@@ -60,9 +60,21 @@ Huit constats structurants, qu'une lecture de la documentation publique de Combo
| **Paie** | **Export vers Silae** (§8). Aucun moteur de paie, aucun bulletin, aucune DSN dans PlanFlow. |
| **Pointeuse** | **Hors périmètre.** Pas de borne, pas de PWA kiosque, pas de pointage matériel. Les heures réelles sont saisies et validées par le manager (§7.3). |
| **Convention d'amorce** | **IDCC 1517**, moteur paramétrable pour en ajouter d'autres. |
| **Stack** | Next.js 15 (App Router, TypeScript strict) · PostgreSQL 16 + Prisma · Auth.js v5 · Tailwind + shadcn/ui · Zod · Vitest + Playwright · pnpm · `docker-compose` auto-hébergé. |
| **Stack** | Next.js 16 (App Router, TypeScript strict) · PostgreSQL 16 + Prisma 7 · **sessions maison en base** · Tailwind 4 · Zod · Vitest + Playwright · pnpm · `docker-compose` auto-hébergé. |
| **Mobile** | PWA installable, responsive. Pas d'application native. |
### Écarts constatés à la réalisation
Trois choix de §2 ont été révisés au WP-00, après confrontation aux versions réellement disponibles.
| Sujet | Plan initial | Retenu | Raison |
|---|---|---|---|
| Next.js | 15 | **16.3** | Version stable courante. Démarrer une majeure en retard n'apporte rien. |
| Authentification | Auth.js v5 | **Sessions en base** | Auth.js v5 est toujours en beta. Le besoin se limite à identifiants et invitation, sans OAuth — et la matrice n° 23 impose la **révocation de session**, immédiate avec des sessions en base, malaisée avec des JWT. |
| Convention | `middleware.ts` | **`proxy.ts`** | Next 16 a renommé la convention ; `middleware` est déprécié. |
Prisma 7 a par ailleurs déplacé l'URL de connexion du schéma vers `prisma.config.ts`, et l'application passe désormais par un adaptateur `pg` explicite — c'est ce point d'accroche qui recevra l'extension de scoping multi-tenant au WP-01.
**Hors périmètre v1**, à ne pas construire : moteur de paie, DSN, bulletins de paie, distribution de bulletins, signature électronique qualifiée, transmission DPAE à l'URSSAF, connecteurs de caisse, abonnement et facturation, planning prédictif, auto-assignation.
**Conservés mais différés en fin de parcours** : articles et conversations internes (WP-11, optionnel). Les analyses RH sont bien dans le périmètre v1 (WP-09).
+97
View File
@@ -0,0 +1,97 @@
# PlanFlow
Gestion du personnel, des plannings et des temps, multi-établissements, auto-hébergée. La paie est **exportée vers Silae** ; PlanFlow ne produit ni bulletin ni DSN.
La spécification de construction est [`PLAN.md`](PLAN.md). Elle est normative : en cas d'écart entre le code et le plan, c'est le plan qui a raison, ou le plan qui doit être corrigé — jamais l'écart qui s'installe.
| Document | Rôle |
|---|---|
| [`PLAN.md`](PLAN.md) | Spécification : périmètre, modèle de données, règles, lots de travail |
| [`matrice-conformite-rh-france-2026.md`](matrice-conformite-rh-france-2026.md) | Exigences réglementaires françaises, cotées P0/P1/P2 |
| [`Audit Combo/`](Audit%20Combo/INDEX.md) | Audit fonctionnel du produit de référence |
## État
**WP-00 — socle.** Next.js, Prisma, base de données, en-têtes de sécurité, tests, CI, image Docker. Aucun écran métier : ils arrivent à partir de WP-01 (tenancy, identité, autorisation).
## Démarrer
### Avec Docker
```bash
cp .env.example .env
# Renseigner POSTGRES_PASSWORD et ENCRYPTION_KEY (voir ci-dessous)
docker compose up --build
```
L'application écoute sur <http://localhost:3000>. Les migrations s'appliquent au démarrage du conteneur.
### En local
Nécessite Node 22, pnpm 10 et un PostgreSQL 16 accessible.
```bash
pnpm install
cp .env.example .env # renseigner DATABASE_URL et ENCRYPTION_KEY
pnpm db:generate
pnpm db:deploy
pnpm dev
```
### Clé de chiffrement
`ENCRYPTION_KEY` chiffre au repos les colonnes sensibles exigées par le plan (§3.6) : NIR, IBAN, BIC.
```bash
openssl rand -base64 32
```
Elle vit **hors de la base** : une sauvegarde volée ne doit pas suffire à lire ces colonnes. La perdre rend ces données irrécupérables — la sauvegarder séparément et documenter sa rotation.
## Vérifier
```bash
pnpm verify # typecheck + lint + tests unitaires
pnpm test:e2e # build, serveur standalone, tests de bout en bout
```
`pnpm verify` est ce que la CI exécute sur chaque *pull request*, suivi du build et des tests end-to-end.
## Choix structurants
**Aucun traceur tiers.** L'audit du produit de référence a intercepté 2102 requêtes de traçage — Segment, LinkedIn Ads, Google Ads, DoubleClick, Clarity, Hotjar — et aucune requête métier. Une application RH ne doit pas envoyer un contexte de navigation portant sur des salariés identifiables à des régies publicitaires. Deux garde-fous rendent la règle vérifiable plutôt que déclarative :
- une Content-Security-Policy qui ne nomme **aucune** origine externe, posée par requête avec un nonce (`src/proxy.ts`) ;
- un test qui échoue si une dépendance de traçage apparaît dans `package.json`.
Pour de la télémétrie technique, passer par une interface abstraite auto-hébergée.
**Le serveur testé est celui qui est déployé.** Les tests end-to-end lancent le serveur `standalone`, celui que l'image Docker exécute — pas `next dev`, dont la politique de sécurité est volontairement plus permissive.
## Structure
```
src/
├── app/ écrans (App Router)
├── lib/
│ ├── env.ts contrat d'environnement, validé à l'import
│ └── security/csp.ts politique de sécurité, fonction pure et testable
├── server/
│ ├── db.ts client Prisma — le scoping multi-tenant s'y greffe au WP-01
│ └── health.ts
└── proxy.ts en-têtes de sécurité par requête
prisma/ schéma et migrations
tests/
├── unit/ Vitest
└── e2e/ Playwright
```
## Écarts assumés par rapport au plan
Trois choix diffèrent de ce qu'annonçait `PLAN.md` §2, et le plan a été mis à jour en conséquence.
| Sujet | Plan initial | Retenu | Raison |
|---|---|---|---|
| Next.js | 15 | **16.3** | Version stable courante ; démarrer un greenfield une majeure en retard n'a pas de contrepartie. |
| Authentification | Auth.js v5 | **Sessions maison en base** | Auth.js v5 est encore en beta. Le besoin se limite à identifiants et invitation, sans OAuth, et la matrice de conformité (n° 23) impose la **révocation de session** — immédiate avec des sessions en base, malaisée avec des jetons JWT. |
| Convention Next | `middleware.ts` | **`proxy.ts`** | Next 16 a renommé la convention ; `middleware` est déprécié. |
+54
View File
@@ -0,0 +1,54 @@
# Brief design — PlanFlow
À coller dans [Claude Design](https://claude.ai/design). Le résultat revient dans le dépôt par « Send to Claude Code Web », ou en déposant les fichiers dans `design/`.
---
## Prompt
> Conçois un design system pour **PlanFlow**, une application RH de gestion des plannings et des temps, auto-hébergée, utilisée par une enseigne de commerce de détail non alimentaire multi-établissements.
>
> **Direction : organique professionnel.** Chaleureux et humain sans être décoratif ; sobre et crédible sans être froid. On rejette autant le SaaS bleu clinique que le pastel « wellness ». Pense matières naturelles et lumière du jour plutôt que néon et verre : verts et terres désaturés, blancs cassés légèrement chauds, courbes douces mais assumées. L'outil sert à décider, pas à contempler — l'ornement qui n'aide pas à lire est du bruit.
>
> **Qui l'utilise.** Des responsables de magasin qui construisent un planning hebdomadaire pour vingt à quatre-vingts personnes, souvent debout, souvent pressés, parfois sur un écran médiocre en réserve. Et des gestionnaires RH qui passent des heures sur des tableaux denses. Confort de lecture prolongée et lisibilité en conditions dégradées priment sur l'effet de style.
>
> **Le problème central : la grille de planning.** Lignes = salariés, colonnes = les sept jours. Chaque cellule peut contenir plusieurs créneaux colorés par poste, des barres d'absence qui courent sur plusieurs jours, des badges d'écart, une marque de validation. Chaque ligne porte cinq compteurs. La couleur y **porte du sens**, elle n'est pas décorative.
>
> Il me faut donc :
> - une palette catégorielle d'**au moins dix teintes de postes**, distinguables entre elles à petite taille et **pour les trois formes principales de daltonisme** ;
> - ces teintes doivent rester lisibles avec du texte par-dessus, en clair comme en sombre ;
> - une distinction nette entre teinte de poste (catégorielle, sans hiérarchie) et couleurs de statut (sémantiques : alerte de convention, écart, validé, non publié).
>
> **Contraintes fermes :**
> - **Aucune ressource externe.** La politique de sécurité de l'application interdit toute origine tierce : pas de Google Fonts, pas de CDN. Propose une pile de polices système, ou une police libre auto-hébergeable en précisant laquelle.
> - **WCAG 2.2 AA** sur tout, y compris les états de survol et de focus, et un focus visible au clavier — la grille se pilote au clavier.
> - **Thèmes clair et sombre**, tokens définis pour les deux.
> - Interface en **français** : prévoir des libellés plus longs qu'en anglais, les colonnes ne doivent pas casser.
> - Densité élevée assumée : c'est un outil de production, pas une page marketing.
>
> **Livre :**
> 1. Les tokens — couleur, typographie, espacement, rayons, élévation, durées d'animation — en variables CSS, thèmes clair et sombre.
> 2. Une échelle typographique tenant la lecture prolongée de tableaux, avec des chiffres tabulaires pour les compteurs et les horaires.
> 3. Les composants : bouton (primaire, secondaire, discret, danger), champ de saisie, sélecteur, case à cocher, badge, pastille de statut, tableau dense, onglets, fenêtre modale, panneau latéral, message vide, message d'erreur, squelette de chargement, notification.
> 4. **Le bloc de créneau** de la grille de planning, dans ses états : brouillon, publié, validé, en alerte, non assigné, en cours de déplacement.
> 5. **La barre d'absence** multi-jours, avec son étiquette et sa durée.
> 6. Le bandeau de compteurs d'une ligne salarié : cinq valeurs, dont un écart qui peut être positif ou négatif.
> 7. Une démonstration de la grille complète sur une semaine réaliste, en clair et en sombre.
>
> Montre la palette de postes appliquée à une vraie grille, pas seulement en nuancier : c'est là qu'une palette échoue.
---
## Pourquoi ces contraintes
**Pas de ressources externes** — la Content-Security-Policy de PlanFlow ne nomme aucune origine tierce (`src/lib/security/csp.ts`), et un test échoue si cela change. Une police appelée depuis un CDN serait bloquée par le navigateur. Ce n'est pas un détail de style : `PLAN.md` §3.7 en fait un invariant, parce qu'une application RH ne doit rien envoyer à un tiers.
**Daltonisme** — la couleur de poste est de l'information, pas de la décoration. Si deux postes se confondent, le planning devient faux à la lecture. Prévoir un second canal — libellé, motif, initiale — plutôt que de compter sur la seule teinte.
**Chiffres tabulaires** — les compteurs et horaires s'empilent en colonnes. Des chiffres à chasse variable les font danser d'une ligne à l'autre et rendent la comparaison pénible.
**Libellés français** — « Dépublier », « Repos compensateur », « Convention collective » sont sensiblement plus longs que leurs équivalents anglais. Une grille calibrée sur de l'anglais casse.
## Intégration
Les tokens atterrissent dans `src/app/globals.css` (Tailwind 4 lit `@theme` directement depuis le CSS), les composants dans `src/components/ui/`. La grille elle-même arrive au WP-04 ; d'ici là, le design system peut se construire en parallèle des lots WP-01 à WP-03.
+54
View File
@@ -0,0 +1,54 @@
name: planflow
services:
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-planflow}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD est requis}
POSTGRES_DB: ${POSTGRES_DB:-planflow}
# Deterministic collation: ordering of employee names must not depend on
# the host locale, or exports differ between machines.
LANG: C.UTF-8
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER:-planflow} -d ${POSTGRES_DB:-planflow}']
interval: 5s
timeout: 5s
retries: 10
# Not published by default: nothing outside the compose network needs the
# database, and an HR dataset should not be one firewall rule from the world.
expose:
- '5432'
app:
build:
context: .
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
NODE_ENV: production
DATABASE_URL: postgresql://${POSTGRES_USER:-planflow}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-planflow}
ENCRYPTION_KEY: ${ENCRYPTION_KEY:?ENCRYPTION_KEY est requis — voir .env.example}
APP_URL: ${APP_URL:-http://localhost:3000}
ports:
- '${APP_PORT:-3000}:3000'
healthcheck:
test:
[
'CMD',
'node',
'-e',
"fetch('http://127.0.0.1:3000/api/sante').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
]
interval: 15s
timeout: 5s
retries: 5
start_period: 30s
volumes:
db-data:
+33
View File
@@ -0,0 +1,33 @@
import coreWebVitals from 'eslint-config-next/core-web-vitals';
import typescript from 'eslint-config-next/typescript';
/**
* eslint-config-next 16 ships flat configs directly, so no FlatCompat bridge.
*/
const config = [
{
ignores: [
'node_modules/**',
'.next/**',
'next-env.d.ts',
'playwright-report/**',
'test-results/**',
'.pgdata/**',
],
},
...coreWebVitals,
...typescript,
{
rules: {
// An unused binding is usually a leftover, and a leftover in an
// authorisation path is a security bug. The _ prefix marks the ones that
// are deliberate.
'@typescript-eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
],
},
},
];
export default config;
+35
View File
@@ -0,0 +1,35 @@
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
// Docker image: ship only what the server needs.
output: 'standalone',
reactStrictMode: true,
poweredByHeader: false,
// Security headers that do not need a per-request nonce.
// The Content-Security-Policy is set in src/proxy.ts because it does.
async headers() {
return [
{
source: '/:path*',
headers: [
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'Referrer-Policy', value: 'no-referrer' },
{
key: 'Strict-Transport-Security',
value: 'max-age=63072000; includeSubDomains; preload',
},
// No browser feature in this app needs these. Denying them keeps a
// future dependency from silently reaching for a camera or a GPS fix.
{
key: 'Permissions-Policy',
value: 'camera=(), microphone=(), geolocation=(), interest-cohort=()',
},
],
},
];
},
};
export default nextConfig;
+64
View File
@@ -0,0 +1,64 @@
{
"name": "planflow",
"version": "0.1.0",
"private": true,
"type": "module",
"packageManager": "pnpm@10.33.0",
"engines": {
"node": ">=22"
},
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test",
"db:generate": "prisma generate",
"db:migrate": "prisma migrate dev",
"db:deploy": "prisma migrate deploy",
"db:seed": "tsx prisma/seed.ts",
"db:studio": "prisma studio",
"verify": "pnpm typecheck && pnpm lint && pnpm test"
},
"prisma": {
"seed": "tsx prisma/seed.ts"
},
"dependencies": {
"@node-rs/argon2": "2.0.2",
"@prisma/adapter-pg": "7.9.1",
"@prisma/client": "7.9.1",
"next": "16.3.0",
"pg": "8.22.0",
"react": "19.2.8",
"react-dom": "19.2.8",
"zod": "4.4.3"
},
"devDependencies": {
"@playwright/test": "1.62.1",
"@tailwindcss/postcss": "4.3.3",
"@types/node": "22.19.4",
"@types/pg": "8.20.4",
"@types/react": "19.2.18",
"@types/react-dom": "19.2.4",
"dotenv": "^17.4.2",
"eslint": "9.39.1",
"eslint-config-next": "16.3.0",
"prisma": "7.9.1",
"tailwindcss": "4.3.3",
"tsx": "4.20.6",
"typescript": "5.9.3",
"vitest": "4.1.10"
},
"pnpm": {
"onlyBuiltDependencies": [
"@prisma/client",
"@prisma/engines",
"esbuild",
"prisma",
"unrs-resolver"
]
}
}
+55
View File
@@ -0,0 +1,55 @@
import { defineConfig, devices } from '@playwright/test';
const PORT = Number(process.env.E2E_PORT ?? 3100);
const baseURL = `http://127.0.0.1:${PORT}`;
export default defineConfig({
testDir: './tests/e2e',
fullyParallel: true,
forbidOnly: Boolean(process.env.CI),
retries: process.env.CI ? 2 : 0,
// Serial in CI so the single Postgres service is not raced.
...(process.env.CI ? { workers: 1 } : {}),
reporter: process.env.CI ? [['github'], ['html', { open: 'never' }]] : 'list',
use: {
baseURL,
trace: 'on-first-retry',
},
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
// Certains environnements fournissent déjà un Chromium dont la révision
// ne correspond pas à celle qu'attend cette version de Playwright.
// PLAYWRIGHT_CHROMIUM_PATH permet de le réutiliser plutôt que d'en
// télécharger un second.
...(process.env.PLAYWRIGHT_CHROMIUM_PATH
? {
launchOptions: {
executablePath: process.env.PLAYWRIGHT_CHROMIUM_PATH,
},
}
: {}),
},
},
],
webServer: {
// Exercise the standalone server, which is what the Docker image runs.
// `next start` is not compatible with `output: standalone`, and testing a
// different server than the one deployed defeats the purpose of this suite.
command: [
'pnpm build',
'cp -r .next/static .next/standalone/.next/static',
'cp -r public .next/standalone/public',
`node .next/standalone/server.js`,
].join(' && '),
url: baseURL,
env: { PORT: String(PORT), HOSTNAME: '127.0.0.1' },
reuseExistingServer: !process.env.CI,
timeout: 180_000,
},
});
+6462
View File
File diff suppressed because it is too large. Load diff
+8
View File
@@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
'@tailwindcss/postcss': {},
},
};
export default config;
+20
View File
@@ -0,0 +1,20 @@
import 'dotenv/config';
import { defineConfig, env } from 'prisma/config';
/**
* Prisma 7 moved the migration connection URL out of schema.prisma.
*
* Only the CLI reads this file. The application connects through the pg driver
* adapter in src/server/db.ts, which is what lets WP-01 wrap every query in the
* tenant-scoping extension required by PLAN.md §3.1.
*/
export default defineConfig({
schema: 'prisma/schema.prisma',
migrations: {
path: 'prisma/migrations',
seed: 'tsx prisma/seed.ts',
},
datasource: {
url: env('DATABASE_URL'),
},
});
@@ -0,0 +1,10 @@
-- Extensions PostgreSQL dont dépendent les lots suivants.
--
-- citext : adresses e-mail insensibles à la casse, sans dupliquer un champ
-- normalisé à côté de la valeur saisie.
-- pgcrypto: primitives utilisées par les contrôles d'intégrité (empreintes
-- d'exports, PLAN.md §8.3). Le chiffrement des colonnes sensibles
-- reste applicatif, clé hors base (PLAN.md §3.6).
CREATE EXTENSION IF NOT EXISTS "citext";
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
+3
View File
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g. Git)
provider = "postgresql"
+14
View File
@@ -0,0 +1,14 @@
// PlanFlow — schéma de données.
//
// WP-00 n'installe que la plomberie : datasource, generator et les extensions
// PostgreSQL dont les lots suivants dépendent. Les modèles métier arrivent au
// WP-01 (tenancy, identité, autorisation) — voir PLAN.md §4.
generator client {
provider = "prisma-client-js"
}
// Prisma 7 : l'URL de connexion vit dans prisma.config.ts, plus ici.
datasource db {
provider = "postgresql"
}
View File
Whitespace-only changes.
+25
View File
@@ -0,0 +1,25 @@
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { EmptyState } from '@/components/ui/Card';
export const metadata = { title: 'Écran à venir · PlanFlow' };
export default async function BientotPage({
searchParams,
}: {
searchParams: Promise<{ ecran?: string }>;
}) {
const { ecran } = await searchParams;
const label = ecran ?? 'Cet écran';
return (
<PageBody>
<PageHeader title={label} subtitle="Nantes Atlantis" />
<div className="rounded-3 border border-dashed border-line-2 bg-surface">
<EmptyState
title="Écran à venir"
description={`« ${label} » fait partie du périmètre de PlanFlow mais n'est pas encore construit. L'entrée reste dans la navigation pour que celle-ci reflète le produit visé plutôt que son état d'avancement.`}
/>
</div>
</PageBody>
);
}
+148
View File
@@ -0,0 +1,148 @@
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { Card, CardHeader } from '@/components/ui/Card';
import { cx } from '@/lib/cx';
import {
CALENDAR_ABSENCES,
MONTH_LABEL,
MONTH_LENGTH,
MONTH_OFFSET,
PENDING_REQUESTS,
} from '@/lib/demo/conges';
export const metadata = { title: 'Calendrier des absences · PlanFlow' };
const WEEKDAYS = [
'Lundi',
'Mardi',
'Mercredi',
'Jeudi',
'Vendredi',
'Samedi',
'Dimanche',
];
const KIND_STYLES: Record<string, string> = {
cp: 'bg-info-soft text-info-soft-ink border-info',
rtt: 'bg-accent-soft text-accent-soft-ink border-accent',
maladie: 'bg-surface-3 text-ink-2 border-line-3',
'sans-solde': 'bg-surface-2 text-ink-2 border-line-3',
attente: 'bg-warn-soft text-warn-soft-ink border-warn',
};
export default function CongesPage() {
const cells = Array.from({ length: 42 }, (_, index) => {
const dayNumber = index - MONTH_OFFSET + 1;
const inMonth = dayNumber >= 1 && dayNumber <= MONTH_LENGTH;
const items = inMonth
? CALENDAR_ABSENCES.filter(
(absence) => dayNumber >= absence.from && dayNumber <= absence.to,
)
: [];
return { index, dayNumber, inMonth, items, weekend: index % 7 > 4 };
});
return (
<PageBody>
<PageHeader
title="Calendrier des absences"
subtitle={`${MONTH_LABEL} · Nantes Atlantis · ${PENDING_REQUESTS.length} demandes en attente`}
actions={
<>
<Button>Exporter</Button>
<Button variant="primary">Saisir une absence</Button>
</>
}
/>
<Card>
<div className="grid grid-cols-7 border-b border-line-2 bg-surface-2">
{WEEKDAYS.map((day) => (
<div
key={day}
className="border-r border-line-1 px-2 py-2 text-center text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase last:border-r-0"
>
{day}
</div>
))}
</div>
<div className="grid grid-cols-7">
{cells.map((cell) => (
<div
key={cell.index}
className={cx(
'min-h-24 border-r border-b border-line-1 p-1.5 last:border-r-0',
!cell.inMonth && 'bg-surface-2',
cell.inMonth && cell.weekend && 'bg-surface-2',
)}
>
<span
className={cx(
'tnum block text-micro font-semibold',
cell.inMonth ? 'text-ink-1' : 'text-ink-3',
)}
>
{cell.inMonth ? cell.dayNumber : ''}
</span>
<ul className="mt-1 flex flex-col gap-0.5">
{cell.items.slice(0, 3).map((absence) => (
<li
key={`${absence.who}-${absence.type}`}
className={cx(
'truncate rounded-1 border px-1 py-px text-micro',
KIND_STYLES[absence.kind],
)}
title={`${absence.who} · ${absence.type}`}
>
{absence.who.split(' ')[0]} · {absence.type}
</li>
))}
{cell.items.length > 3 ? (
<li className="px-1 text-micro text-ink-3">
+{cell.items.length - 3}
</li>
) : null}
</ul>
</div>
))}
</div>
</Card>
<Card>
<span id="attente" />
<CardHeader
title="Demandes en attente"
badge={<Badge tone="warn">{PENDING_REQUESTS.length}</Badge>}
/>
<ul>
{PENDING_REQUESTS.map((request) => (
<li
key={`${request.who}-${request.range}`}
className="flex flex-wrap items-center gap-3 border-b border-line-1 px-4 py-3 last:border-b-0"
>
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium">
{request.who} · {request.type}
</span>
<span className="block text-micro text-ink-3">
{request.note}
</span>
</span>
<span className="tnum flex-none text-xs text-ink-2">
{request.range}
</span>
<Badge tone="neutral">{request.days}</Badge>
<span className="flex flex-none gap-2">
<Button size="sm">Refuser</Button>
<Button size="sm" variant="primary">
Accepter
</Button>
</span>
</li>
))}
</ul>
</Card>
</PageBody>
);
}
+147
View File
@@ -0,0 +1,147 @@
import { notFound } from 'next/navigation';
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { Card, CardHeader } from '@/components/ui/Card';
import { POSTE_LABELS, posteShort, posteTokens } from '@/lib/design/postes';
import { EMPLOYEES, findEmployee } from '@/lib/demo/equipe';
import {
FICHE_COUNTERS,
FICHE_DOCUMENTS,
FICHE_SHIFTS,
} from '@/lib/demo/fiche';
import { fullName, initials } from '@/lib/demo/types';
export function generateStaticParams() {
return EMPLOYEES.map((employee) => ({ id: employee.id }));
}
export default async function FichePage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const employee = findEmployee(id);
if (!employee) notFound();
return (
<PageBody>
<PageHeader
title={fullName(employee)}
subtitle={`${employee.job} · Nantes Atlantis · entrée le ${employee.since}`}
actions={
<>
<Button>Documents</Button>
<Button variant="primary">Modifier le contrat</Button>
</>
}
/>
<div className="flex flex-wrap items-center gap-3 rounded-3 border border-line-1 bg-surface p-4">
<span
aria-hidden
className="flex size-11 flex-none items-center justify-center rounded-full bg-surface-3 text-sm font-semibold text-ink-2"
>
{initials(employee)}
</span>
<div className="min-w-0">
<p className="text-sm font-medium">{employee.contract}</p>
<p className="text-micro text-ink-3">
Poste principal · {POSTE_LABELS[employee.poste]}
</p>
</div>
<span className="flex-1" />
{employee.forfaitJours ? (
<Badge tone="accent">Forfait jours · 218 j</Badge>
) : null}
<ul className="flex flex-wrap items-center gap-2">
{FICHE_COUNTERS.map((counter) => (
<li
key={counter.label}
className="rounded-2 border border-line-1 bg-surface-2 px-3 py-1.5"
>
<span className="block text-micro text-ink-3">
{counter.label}
</span>
<span
className={`tnum block text-sm font-semibold ${
counter.tone === 'warn' ? 'text-warn-soft-ink' : 'text-ink-1'
}`}
>
{counter.value}
</span>
</li>
))}
</ul>
</div>
<div className="grid gap-3 [grid-template-columns:repeat(auto-fit,minmax(400px,1fr))]">
<Card>
<CardHeader
title="Créneaux de la semaine"
action={
<Button size="sm" variant="ghost">
Voir le planning
</Button>
}
/>
<ul>
{FICHE_SHIFTS.map((shift, index) => {
const tokens = posteTokens(shift.poste);
return (
<li
key={`${shift.day}-${index}`}
className="flex items-center gap-3 border-b border-line-1 px-4 py-2.5 last:border-b-0"
>
<span className="tnum w-28 flex-none text-xs text-ink-2">
{shift.day}
</span>
<span
className="flex-none rounded-2 px-1.5 py-0.5 text-micro font-semibold tracking-wide"
style={{
background: tokens.bg,
color: tokens.fg,
border: `1px solid ${tokens.edge}`,
}}
title={POSTE_LABELS[shift.poste]}
>
{posteShort(shift.poste)}
</span>
<span className="tnum flex-1 text-sm">{shift.time}</span>
<span className="tnum flex-none text-xs text-ink-2">
{shift.worked}
</span>
<Badge tone={shift.tone}>{shift.state}</Badge>
</li>
);
})}
</ul>
</Card>
<Card>
<CardHeader title="Documents" />
<ul>
{FICHE_DOCUMENTS.map((document) => (
<li
key={document.label}
className="flex items-center gap-3 border-b border-line-1 px-4 py-3 last:border-b-0"
>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">
{document.label}
</span>
<span className="tnum block text-micro text-ink-3">
{document.date}
</span>
</span>
<Badge tone={document.tone}>{document.state}</Badge>
</li>
))}
</ul>
</Card>
</div>
</PageBody>
);
}
+118
View File
@@ -0,0 +1,118 @@
import Link from 'next/link';
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { Badge, type Tone } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { POSTE_LABELS, posteShort, posteTokens } from '@/lib/design/postes';
import { EMPLOYEES } from '@/lib/demo/equipe';
import { fullName, initials } from '@/lib/demo/types';
export const metadata = { title: 'Équipe · PlanFlow' };
const STATUS: Record<string, { label: string; tone: Tone }> = {
actif: { label: 'Actif', tone: 'ok' },
essai: { label: 'Période d’essai', tone: 'info' },
sortie: { label: 'Sortie prévue', tone: 'warn' },
};
export default function EquipePage() {
const actifs = EMPLOYEES.filter((e) => e.status !== 'sortie').length;
const essai = EMPLOYEES.filter((e) => e.status === 'essai').length;
return (
<PageBody>
<PageHeader
title="Équipe"
subtitle={`Nantes Atlantis · ${actifs} salariés actifs, ${essai} en période d’essai`}
actions={
<>
<Button>Registre unique du personnel</Button>
<Button variant="primary">Nouvelle embauche</Button>
</>
}
/>
<div className="overflow-x-auto rounded-3 border border-line-1 bg-surface">
<table className="w-full min-w-[880px] border-collapse text-sm">
<thead>
<tr className="border-b border-line-2 bg-surface-2 text-left text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase">
<th scope="col" className="px-4 py-2.5">
Collaborateur
</th>
<th scope="col" className="px-4 py-2.5">
Poste principal
</th>
<th scope="col" className="px-4 py-2.5">
Contrat
</th>
<th scope="col" className="px-4 py-2.5">
Entrée
</th>
<th scope="col" className="px-4 py-2.5">
Statut
</th>
</tr>
</thead>
<tbody>
{EMPLOYEES.map((employee) => {
const tokens = posteTokens(employee.poste);
const status = STATUS[employee.status] ?? STATUS.actif!;
return (
<tr
key={employee.id}
className="border-b border-line-1 last:border-b-0 hover:bg-surface-2"
>
<td className="px-4 py-2.5">
<Link
href={`/equipe/${employee.id}`}
className="flex items-center gap-2.5 rounded-2"
>
<span
aria-hidden
className="flex size-7 flex-none items-center justify-center rounded-full bg-surface-3 text-micro font-semibold text-ink-2"
>
{initials(employee)}
</span>
<span className="min-w-0">
<span className="block truncate font-medium">
{fullName(employee)}
</span>
<span className="block truncate text-micro text-ink-3">
{employee.job}
</span>
</span>
</Link>
</td>
<td className="px-4 py-2.5">
<span
className="inline-flex items-center gap-1.5 rounded-2 px-2 py-0.5 text-micro"
style={{
background: tokens.bg,
color: tokens.fg,
border: `1px solid ${tokens.edge}`,
}}
>
<span className="font-semibold tracking-wide">
{posteShort(employee.poste)}
</span>
{POSTE_LABELS[employee.poste]}
</span>
</td>
<td className="tnum px-4 py-2.5 text-ink-2">
{employee.contract}
</td>
<td className="tnum px-4 py-2.5 text-ink-2">
{employee.since}
</td>
<td className="px-4 py-2.5">
<Badge tone={status.tone}>{status.label}</Badge>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</PageBody>
);
}
+7
View File
@@ -0,0 +1,7 @@
import type { ReactNode } from 'react';
import { AppShell } from '@/components/shell/AppShell';
export default function AppLayout({ children }: { children: ReactNode }) {
return <AppShell>{children}</AppShell>;
}
+127
View File
@@ -0,0 +1,127 @@
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { Card, CardHeader } from '@/components/ui/Card';
import { ENTRIES, TILES, TODOS } from '@/lib/demo/apercu';
export const metadata = { title: 'Aperçu RH · PlanFlow' };
const TILE_SWATCH: Record<string, string> = {
warn: 'bg-warn-soft border-warn',
info: 'bg-info-soft border-info',
accent: 'bg-accent-soft border-accent',
danger: 'bg-danger-soft border-danger',
};
export default function ApercuPage() {
return (
<PageBody>
<PageHeader
title="Aperçu RH"
subtitle="Vos tâches en cours sur Nantes Atlantis · lundi 10 août 2026"
actions={
<>
<Button>Exporter</Button>
<Button variant="primary">Nouvelle embauche</Button>
</>
}
/>
<div className="grid gap-2.5 [grid-template-columns:repeat(auto-fit,minmax(232px,1fr))]">
{TILES.map((tile) => (
<button
key={tile.label}
type="button"
className="flex cursor-pointer items-center gap-3 rounded-3 border border-line-1 bg-surface px-4 py-3.5 text-left transition-[border-color,box-shadow] duration-[var(--d-1)] ease-organic hover:border-line-3 hover:shadow-e1"
>
<span
aria-hidden
className={`size-8 flex-none rounded-2 border ${TILE_SWATCH[tile.tone] ?? ''}`}
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-xs text-ink-2">
{tile.label}
</span>
<span className="tnum block text-xl leading-tight font-semibold">
{tile.value}
</span>
</span>
<span aria-hidden className="flex-none text-ink-3">
›
</span>
</button>
))}
</div>
<div className="grid gap-3 [grid-template-columns:repeat(auto-fit,minmax(380px,1fr))]">
<Card>
<CardHeader
title="Entrées à venir"
badge={<Badge tone="accent">4</Badge>}
action={
<Button size="sm" variant="ghost">
Voir tout
</Button>
}
/>
<ul>
{ENTRIES.map((entry) => (
<li
key={entry.name}
className="flex items-center gap-3 border-b border-line-1 px-4 py-2.5 last:border-b-0 hover:bg-surface-2"
>
<span
aria-hidden
className="flex size-7 flex-none items-center justify-center rounded-full bg-surface-3 text-micro font-semibold text-ink-2"
>
{entry.initials}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">
{entry.name}
</span>
<span className="block truncate text-micro text-ink-3">
{entry.job}
</span>
</span>
<span className="tnum flex-none text-xs text-ink-2">
{entry.date}
</span>
<Badge tone={entry.tone}>{entry.state}</Badge>
</li>
))}
</ul>
</Card>
<Card>
<CardHeader title="À traiter cette semaine" />
<ul>
{TODOS.map((todo) => (
<li
key={todo.title}
className="flex items-start gap-3 border-b border-line-1 px-4 py-3 last:border-b-0 hover:bg-surface-2"
>
<span
aria-hidden
className="mt-1.5 size-2 flex-none rounded-full"
style={{ background: todo.dot }}
/>
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium">
{todo.title}
</span>
<span className="mt-0.5 block text-xs leading-[var(--lh-prose)] text-ink-2">
{todo.detail}
</span>
</span>
<span className="tnum flex-none whitespace-nowrap text-micro text-ink-3">
{todo.when}
</span>
</li>
))}
</ul>
</Card>
</div>
</PageBody>
);
}
+48
View File
@@ -0,0 +1,48 @@
import { DayTimeline, type DayLane } from '@/components/planning/DayTimeline';
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { UNASSIGNED_ROW, WEEK_ROWS } from '@/lib/demo/semaine';
export const metadata = { title: 'Planning · mercredi 12 août · PlanFlow' };
/** Mercredi = index 2 dans la semaine de démonstration. */
const DAY_INDEX = 2;
export default function JourPage() {
const lanes: DayLane[] = WEEK_ROWS.map((row) => ({
employee: row.employee,
shifts: row.days[DAY_INDEX] ?? [],
})).filter((lane) => lane.shifts.length > 0);
const unassigned = UNASSIGNED_ROW.days[DAY_INDEX] ?? [];
if (unassigned.length > 0) {
lanes.push({
employee: UNASSIGNED_ROW.employee,
shifts: unassigned,
unassigned: true,
});
}
return (
<PageBody>
<PageHeader
title="Planning · mercredi 12 août"
subtitle="Nantes Atlantis · amplitude 06:00 – 21:00"
actions={
<>
<Button>Imprimer la journée</Button>
<Button variant="primary">Ajouter un créneau</Button>
</>
}
/>
<div className="flex flex-wrap items-center gap-2">
<Badge tone="accent">{lanes.length} salariés présents</Badge>
<Badge tone="warn">Creux de couverture à 13 h</Badge>
</div>
<DayTimeline lanes={lanes} fromHour={6} toHour={21} />
</PageBody>
);
}
+78
View File
@@ -0,0 +1,78 @@
import { WeekGrid } from '@/components/planning/WeekGrid';
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { POSTE_CODES, POSTE_LABELS, posteTokens } from '@/lib/design/postes';
import { UNASSIGNED_ROW, WEEK_DAYS, WEEK_LABEL, WEEK_ROWS } from '@/lib/demo/semaine';
export const metadata = { title: 'Planning · semaine 33 · PlanFlow' };
export default function SemainePage() {
return (
<PageBody>
<PageHeader
title="Planning · semaine 33"
subtitle={`Nantes Atlantis · ${WEEK_LABEL.split('·')[1]?.trim()} · ${WEEK_ROWS.length} salariés planifiés`}
actions={
<>
<Button>Dupliquer S‑32</Button>
<Button>Imprimer</Button>
<Button variant="primary">Publier la semaine</Button>
</>
}
/>
<div className="flex flex-wrap items-center gap-2">
<Badge tone="warn">2 alertes de convention</Badge>
<Badge tone="info">Samedi non publié</Badge>
<Badge tone="neutral">5 besoins non couverts</Badge>
</div>
<WeekGrid
days={WEEK_DAYS}
rows={WEEK_ROWS}
unassignedRow={UNASSIGNED_ROW}
/>
<PosteLegend />
</PageBody>
);
}
/**
* Légende des postes.
*
* Elle n'est pas décorative : la couleur seule ne dit pas quel poste est
* lequel, et le code à trois lettres imprimé dans chaque bloc n'a de sens que
* si sa correspondance est visible sur la même page.
*/
function PosteLegend() {
return (
<section className="rounded-3 border border-line-1 bg-surface p-4">
<h2 className="mb-3 text-micro font-semibold tracking-[0.08em] text-ink-3 uppercase">
Postes
</h2>
<ul className="flex flex-wrap gap-2">
{POSTE_CODES.map((code) => {
const tokens = posteTokens(code);
return (
<li
key={code}
className="flex items-center gap-1.5 rounded-2 px-2 py-1 text-micro"
style={{
background: tokens.bg,
color: tokens.fg,
border: `1px solid ${tokens.edge}`,
}}
>
<span className="font-semibold tracking-wide">
{code.toUpperCase()}
</span>
<span>{POSTE_LABELS[code]}</span>
</li>
);
})}
</ul>
</section>
);
}
+24
View File
@@ -0,0 +1,24 @@
import { NextResponse } from 'next/server';
import { checkDatabase } from '@/server/health';
export const dynamic = 'force-dynamic';
/**
* Point de santé, destiné aux sondes de conteneur.
*
* Il ne dit que « la base répond » : ni version, ni schéma, ni compte. Une
* sonde est joignable sans authentification, elle ne doit donc rien apprendre
* à qui la interroge.
*/
export async function GET() {
const database = await checkDatabase();
return NextResponse.json(
{ status: database.ok ? 'ok' : 'degraded' },
{
status: database.ok ? 200 : 503,
headers: { 'cache-control': 'no-store' },
},
);
}
+367
View File
@@ -0,0 +1,367 @@
@import 'tailwindcss';
/* ==========================================================================
PlanFlow — jetons de design
Portés depuis l'export Claude Design « PlanFlow Design System ».
Les valeurs de couleur sont en oklch : les paliers de clarté restent
perceptuellement réguliers d'une teinte à l'autre, ce dont dépend la palette
catégorielle de postes (§ plus bas).
Déclarés dans @theme pour que Tailwind génère les utilitaires correspondants
(bg-surface, text-ink-1, border-line-1…). Le thème sombre redéfinit les mêmes
variables : les utilitaires compilent vers var(--color-…) et suivent donc.
========================================================================== */
@theme {
--font-ui: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto,
'Helvetica Neue', Arial, sans-serif;
--font-mono: ui-monospace, SFMono-Regular, 'Cascadia Mono', 'Segoe UI Mono',
'Liberation Mono', Menlo, monospace;
--text-micro: 11px;
--text-xs: 12px;
--text-sm: 13px;
--text-md: 14px;
--text-lg: 16px;
--text-xl: 20px;
--text-2xl: 26px;
--text-3xl: 32px;
--radius-1: 3px;
--radius-2: 6px;
--radius-3: 10px;
--radius-4: 14px;
--radius-full: 999px;
--ease-organic: cubic-bezier(0.2, 0.7, 0.3, 1);
--ease-organic-out: cubic-bezier(0, 0.6, 0.35, 1);
/* Neutres chauds — la base « organique » du thème. */
--color-canvas: oklch(0.976 0.008 85);
--color-surface: oklch(0.996 0.005 85);
--color-surface-2: oklch(0.958 0.01 85);
--color-surface-3: oklch(0.932 0.012 85);
--color-line-1: oklch(0.905 0.012 85);
--color-line-2: oklch(0.852 0.014 85);
--color-line-3: oklch(0.735 0.016 85);
--color-ink-1: oklch(0.255 0.02 140);
--color-ink-2: oklch(0.445 0.016 135);
--color-ink-3: oklch(0.545 0.014 120);
--color-accent: oklch(0.44 0.078 158);
--color-accent-hover: oklch(0.385 0.08 158);
--color-accent-press: oklch(0.335 0.072 158);
--color-accent-ink: oklch(0.985 0.008 158);
--color-accent-soft: oklch(0.935 0.03 158);
--color-accent-soft-ink: oklch(0.315 0.06 158);
--color-focus: oklch(0.52 0.16 252);
--color-danger: oklch(0.475 0.145 30);
--color-danger-hover: oklch(0.415 0.145 30);
--color-danger-ink: oklch(0.985 0.006 30);
--color-danger-soft: oklch(0.94 0.035 30);
--color-danger-soft-ink: oklch(0.355 0.105 30);
--color-warn: oklch(0.745 0.125 78);
--color-warn-ink: oklch(0.255 0.055 60);
--color-warn-soft: oklch(0.945 0.045 82);
--color-warn-soft-ink: oklch(0.375 0.085 60);
--color-ok: oklch(0.475 0.095 148);
--color-ok-soft: oklch(0.935 0.035 148);
--color-ok-soft-ink: oklch(0.315 0.07 148);
--color-info: oklch(0.475 0.105 245);
--color-info-soft: oklch(0.935 0.035 245);
--color-info-soft-ink: oklch(0.325 0.08 245);
--shadow-e1: 0 1px 2px oklch(0.3 0.02 85 / 0.08);
--shadow-e2:
0 1px 2px oklch(0.3 0.02 85 / 0.06), 0 6px 14px oklch(0.3 0.02 85 / 0.07);
--shadow-e3:
0 2px 6px oklch(0.3 0.02 85 / 0.08), 0 16px 40px oklch(0.3 0.02 85 / 0.14);
}
:root {
--focus-halo: oklch(0.52 0.16 252 / 0.22);
--lh-tight: 1.2;
--lh-ui: 1.35;
--lh-table: 1.4;
--lh-prose: 1.55;
--d-1: 80ms;
--d-2: 130ms;
--d-3: 200ms;
--d-4: 280ms;
/* Hauteur d'une ligne de grille. Change avec la densité. */
--row-h: 38px;
/* ---- Palette catégorielle de postes -----------------------------------
Douze teintes dérivées d'une même formule : la clarté et le chroma sont
communs, seule la teinte varie, plus un palier alterné (--post-*-t) qui
écarte les teintes voisines. Deux postes ne peuvent donc pas se retrouver
à la même clarté avec des teintes proches — c'est ce qui les garde
distinguables en vision daltonienne, où la teinte seule ne suffit pas.
La couleur ne porte jamais l'information seule : le code du poste est
toujours écrit dans le bloc (voir ShiftChip).
--------------------------------------------------------------------- */
--tier-dir: -1;
--chip-bg-l: 0.905;
--chip-bg-c: 0.042;
--chip-fg-l: 0.315;
--chip-fg-c: 0.055;
--chip-edge-l: 0.52;
--chip-edge-c: 0.12;
--post-cai-h: 250;
--post-cai-t: 0;
--post-vte-h: 162;
--post-vte-t: 0.045;
--post-res-h: 30;
--post-res-t: 0;
--post-acc-h: 205;
--post-acc-t: 0.045;
--post-cab-h: 100;
--post-cab-t: 0;
--post-rss-h: 308;
--post-rss-t: 0.045;
--post-inv-h: 138;
--post-inv-t: 0;
--post-liv-h: 52;
--post-liv-t: 0.045;
--post-enc-h: 276;
--post-enc-t: 0;
--post-clc-h: 340;
--post-clc-t: 0.045;
--post-net-h: 185;
--post-net-t: 0;
--post-for-h: 68;
--post-for-t: 0.045;
/* Dérivation : une seule formule pour les douze postes. Ajouter un poste
revient à déclarer sa teinte et son palier, puis ces trois lignes. */
--post-cai-bg: oklch(
calc(var(--chip-bg-l) + var(--tier-dir) * var(--post-cai-t)) var(--chip-bg-c)
var(--post-cai-h)
);
--post-cai-fg: oklch(var(--chip-fg-l) var(--chip-fg-c) var(--post-cai-h));
--post-cai-edge: oklch(
calc(var(--chip-edge-l) + var(--tier-dir) * var(--post-cai-t)) var(--chip-edge-c)
var(--post-cai-h)
);
--post-vte-bg: oklch(
calc(var(--chip-bg-l) + var(--tier-dir) * var(--post-vte-t)) var(--chip-bg-c)
var(--post-vte-h)
);
--post-vte-fg: oklch(var(--chip-fg-l) var(--chip-fg-c) var(--post-vte-h));
--post-vte-edge: oklch(
calc(var(--chip-edge-l) + var(--tier-dir) * var(--post-vte-t)) var(--chip-edge-c)
var(--post-vte-h)
);
--post-res-bg: oklch(
calc(var(--chip-bg-l) + var(--tier-dir) * var(--post-res-t)) var(--chip-bg-c)
var(--post-res-h)
);
--post-res-fg: oklch(var(--chip-fg-l) var(--chip-fg-c) var(--post-res-h));
--post-res-edge: oklch(
calc(var(--chip-edge-l) + var(--tier-dir) * var(--post-res-t)) var(--chip-edge-c)
var(--post-res-h)
);
--post-acc-bg: oklch(
calc(var(--chip-bg-l) + var(--tier-dir) * var(--post-acc-t)) var(--chip-bg-c)
var(--post-acc-h)
);
--post-acc-fg: oklch(var(--chip-fg-l) var(--chip-fg-c) var(--post-acc-h));
--post-acc-edge: oklch(
calc(var(--chip-edge-l) + var(--tier-dir) * var(--post-acc-t)) var(--chip-edge-c)
var(--post-acc-h)
);
--post-cab-bg: oklch(
calc(var(--chip-bg-l) + var(--tier-dir) * var(--post-cab-t)) var(--chip-bg-c)
var(--post-cab-h)
);
--post-cab-fg: oklch(var(--chip-fg-l) var(--chip-fg-c) var(--post-cab-h));
--post-cab-edge: oklch(
calc(var(--chip-edge-l) + var(--tier-dir) * var(--post-cab-t)) var(--chip-edge-c)
var(--post-cab-h)
);
--post-rss-bg: oklch(
calc(var(--chip-bg-l) + var(--tier-dir) * var(--post-rss-t)) var(--chip-bg-c)
var(--post-rss-h)
);
--post-rss-fg: oklch(var(--chip-fg-l) var(--chip-fg-c) var(--post-rss-h));
--post-rss-edge: oklch(
calc(var(--chip-edge-l) + var(--tier-dir) * var(--post-rss-t)) var(--chip-edge-c)
var(--post-rss-h)
);
--post-inv-bg: oklch(
calc(var(--chip-bg-l) + var(--tier-dir) * var(--post-inv-t)) var(--chip-bg-c)
var(--post-inv-h)
);
--post-inv-fg: oklch(var(--chip-fg-l) var(--chip-fg-c) var(--post-inv-h));
--post-inv-edge: oklch(
calc(var(--chip-edge-l) + var(--tier-dir) * var(--post-inv-t)) var(--chip-edge-c)
var(--post-inv-h)
);
--post-liv-bg: oklch(
calc(var(--chip-bg-l) + var(--tier-dir) * var(--post-liv-t)) var(--chip-bg-c)
var(--post-liv-h)
);
--post-liv-fg: oklch(var(--chip-fg-l) var(--chip-fg-c) var(--post-liv-h));
--post-liv-edge: oklch(
calc(var(--chip-edge-l) + var(--tier-dir) * var(--post-liv-t)) var(--chip-edge-c)
var(--post-liv-h)
);
--post-enc-bg: oklch(
calc(var(--chip-bg-l) + var(--tier-dir) * var(--post-enc-t)) var(--chip-bg-c)
var(--post-enc-h)
);
--post-enc-fg: oklch(var(--chip-fg-l) var(--chip-fg-c) var(--post-enc-h));
--post-enc-edge: oklch(
calc(var(--chip-edge-l) + var(--tier-dir) * var(--post-enc-t)) var(--chip-edge-c)
var(--post-enc-h)
);
--post-clc-bg: oklch(
calc(var(--chip-bg-l) + var(--tier-dir) * var(--post-clc-t)) var(--chip-bg-c)
var(--post-clc-h)
);
--post-clc-fg: oklch(var(--chip-fg-l) var(--chip-fg-c) var(--post-clc-h));
--post-clc-edge: oklch(
calc(var(--chip-edge-l) + var(--tier-dir) * var(--post-clc-t)) var(--chip-edge-c)
var(--post-clc-h)
);
--post-net-bg: oklch(
calc(var(--chip-bg-l) + var(--tier-dir) * var(--post-net-t)) var(--chip-bg-c)
var(--post-net-h)
);
--post-net-fg: oklch(var(--chip-fg-l) var(--chip-fg-c) var(--post-net-h));
--post-net-edge: oklch(
calc(var(--chip-edge-l) + var(--tier-dir) * var(--post-net-t)) var(--chip-edge-c)
var(--post-net-h)
);
--post-for-bg: oklch(
calc(var(--chip-bg-l) + var(--tier-dir) * var(--post-for-t)) var(--chip-bg-c)
var(--post-for-h)
);
--post-for-fg: oklch(var(--chip-fg-l) var(--chip-fg-c) var(--post-for-h));
--post-for-edge: oklch(
calc(var(--chip-edge-l) + var(--tier-dir) * var(--post-for-t)) var(--chip-edge-c)
var(--post-for-h)
);
}
/* Thème sombre : redéfinit les mêmes variables. --tier-dir s'inverse pour que
le palier de clarté pousse vers le clair au lieu du sombre. */
:root[data-theme='dark'] {
--tier-dir: 1;
--chip-bg-l: 0.315;
--chip-bg-c: 0.048;
--chip-fg-l: 0.905;
--chip-fg-c: 0.032;
--chip-edge-l: 0.66;
--chip-edge-c: 0.105;
--color-canvas: oklch(0.185 0.01 150);
--color-surface: oklch(0.225 0.012 150);
--color-surface-2: oklch(0.265 0.012 150);
--color-surface-3: oklch(0.305 0.013 150);
--color-line-1: oklch(0.325 0.012 150);
--color-line-2: oklch(0.395 0.014 150);
--color-line-3: oklch(0.52 0.016 150);
--color-ink-1: oklch(0.955 0.008 95);
--color-ink-2: oklch(0.815 0.012 100);
--color-ink-3: oklch(0.67 0.013 105);
--color-accent: oklch(0.735 0.105 158);
--color-accent-hover: oklch(0.795 0.095 158);
--color-accent-press: oklch(0.68 0.1 158);
--color-accent-ink: oklch(0.195 0.04 158);
--color-accent-soft: oklch(0.3 0.045 158);
--color-accent-soft-ink: oklch(0.87 0.07 158);
--color-focus: oklch(0.76 0.14 252);
--focus-halo: oklch(0.76 0.14 252 / 0.28);
--color-danger: oklch(0.69 0.15 32);
--color-danger-hover: oklch(0.745 0.14 32);
--color-danger-ink: oklch(0.185 0.045 32);
--color-danger-soft: oklch(0.305 0.06 32);
--color-danger-soft-ink: oklch(0.885 0.075 32);
--color-warn: oklch(0.79 0.13 80);
--color-warn-ink: oklch(0.21 0.05 60);
--color-warn-soft: oklch(0.32 0.055 75);
--color-warn-soft-ink: oklch(0.895 0.08 82);
--color-ok: oklch(0.71 0.1 148);
--color-ok-soft: oklch(0.295 0.05 148);
--color-ok-soft-ink: oklch(0.885 0.075 148);
--color-info: oklch(0.705 0.11 245);
--color-info-soft: oklch(0.3 0.05 245);
--color-info-soft-ink: oklch(0.88 0.07 245);
--shadow-e1: 0 1px 2px oklch(0 0 0 / 0.4);
--shadow-e2: 0 1px 2px oklch(0 0 0 / 0.35), 0 6px 14px oklch(0 0 0 / 0.4);
--shadow-e3: 0 2px 6px oklch(0 0 0 / 0.35), 0 16px 40px oklch(0 0 0 / 0.55);
}
/* Densité confortable : une seule variable pilote la hauteur de ligne. */
:root[data-density='confortable'] {
--row-h: 46px;
}
/* ==========================================================================
Base
========================================================================== */
html {
color-scheme: light;
}
html[data-theme='dark'] {
color-scheme: dark;
}
body {
margin: 0;
background: var(--color-canvas);
color: var(--color-ink-1);
font-family: var(--font-ui);
font-size: var(--text-md);
line-height: var(--lh-ui);
-webkit-font-smoothing: antialiased;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
/* Anneau de focus unique, contraste ≥ 3:1 sur tous les fonds y compris les
aplats de poste. La grille se pilote au clavier : il doit rester visible. */
:focus-visible {
outline: 2px solid var(--color-focus);
outline-offset: 2px;
}
/* Compteurs, horaires, écarts : sans chiffres tabulaires les colonnes dansent
d'une ligne à l'autre et la comparaison devient pénible. */
.tnum {
font-variant-numeric: tabular-nums;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 1ms !important;
transition-duration: 1ms !important;
}
}
+32
View File
@@ -0,0 +1,32 @@
import type { Metadata } from 'next';
import { headers } from 'next/headers';
import { THEME_INIT_SCRIPT } from '@/components/shell/ThemeToggle';
import './globals.css';
export const metadata: Metadata = {
title: 'PlanFlow',
description: 'Gestion du personnel, des plannings et des temps.',
};
export default async function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
// Le nonce est posé par requête dans src/proxy.ts. C'est la seule façon
// d'exécuter un script inline sous notre CSP, qui n'autorise pas
// 'unsafe-inline'.
const nonce = (await headers()).get('x-nonce') ?? undefined;
return (
<html lang="fr" data-theme="light" suppressHydrationWarning>
<head>
<script
nonce={nonce}
dangerouslySetInnerHTML={{ __html: THEME_INIT_SCRIPT }}
/>
</head>
<body>{children}</body>
</html>
);
}
+83
View File
@@ -0,0 +1,83 @@
import { cx } from '@/lib/cx';
export type AbsenceKind = 'cp' | 'rtt' | 'maladie' | 'sans-solde' | 'attente';
const KINDS: Record<AbsenceKind, { bg: string; ink: string; edge: string }> = {
cp: {
bg: 'var(--color-info-soft)',
ink: 'var(--color-info-soft-ink)',
edge: 'var(--color-info)',
},
rtt: {
bg: 'var(--color-accent-soft)',
ink: 'var(--color-accent-soft-ink)',
edge: 'var(--color-accent)',
},
maladie: {
bg: 'var(--color-surface-3)',
ink: 'var(--color-ink-2)',
edge: 'var(--color-line-3)',
},
'sans-solde': {
bg: 'var(--color-surface-2)',
ink: 'var(--color-ink-2)',
edge: 'var(--color-line-3)',
},
attente: {
bg: 'var(--color-warn-soft)',
ink: 'var(--color-warn-soft-ink)',
edge: 'var(--color-warn)',
},
};
export interface AbsenceBarProps {
kind: AbsenceKind;
label: string;
/** Durée déjà formatée, ex. « 5 j ». */
duration: string;
/** Index du premier jour couvert, 0 = lundi. */
startDay: number;
/** Nombre de jours couverts. */
span: number;
/** Nombre de colonnes de la grille. */
columns?: number;
className?: string;
}
/**
* Barre d'absence continue sur plusieurs jours.
*
* Positionnée en pourcentage de la largeur de grille plutôt qu'en colonnes CSS :
* la barre chevauche les cellules au lieu de s'y insérer, ce qui laisse voir
* qu'une absence est une période continue et non une suite de jours isolés.
*/
export function AbsenceBar({
kind,
label,
duration,
startDay,
span,
columns = 7,
className,
}: AbsenceBarProps) {
const tone = KINDS[kind];
return (
<div
className={cx(
'pointer-events-none absolute z-10 flex items-center gap-2 overflow-hidden rounded-2 px-2 py-0.5 text-micro font-medium',
className,
)}
style={{
left: `calc(100% / ${columns} * ${startDay} + 3px)`,
width: `calc(100% / ${columns} * ${span} - 6px)`,
background: tone.bg,
color: tone.ink,
border: `1px solid ${tone.edge}`,
}}
>
<span className="truncate">{label}</span>
<span className="tnum ml-auto shrink-0 opacity-80">{duration}</span>
</div>
);
}
+87
View File
@@ -0,0 +1,87 @@
import type { DeltaTone, WeekCountersView } from '@/domain/counters/week';
import { cx } from '@/lib/cx';
const DELTA_TONES: Record<DeltaTone, string> = {
flat: 'text-ink-1',
over: 'bg-warn-soft text-warn-soft-ink',
under: 'bg-danger-soft text-danger-soft-ink',
none: 'text-ink-3',
};
export interface CounterStripProps {
counters: WeekCountersView;
/** Vrai si le contrat est au forfait jours : pas de comparaison horaire. */
forfaitJours?: boolean;
/** Ligne des besoins sans titulaire : il n'y a pas de contrat à comparer. */
unassigned?: boolean;
className?: string;
}
function Cell({ label, value }: { label: string; value: string }) {
return (
<span className="flex items-baseline gap-1">
<span className="text-ink-3">{label}</span>
<span className="tnum text-ink-2">{value}</span>
</span>
);
}
/**
* Les cinq compteurs d'une ligne salarié : contrat, planifié, absences, écart,
* dimanches et repos. Rendus depuis `computeWeekCounters`, jamais recalculés
* localement.
*/
export function CounterStrip({
counters,
forfaitJours = false,
unassigned = false,
className,
}: CounterStripProps) {
// Un besoin non couvert n'a pas de contrat : afficher un écart le compare à
// zéro et fait lire « +14 h » comme un dépassement, alors que c'est
// simplement le volume à pourvoir.
if (unassigned) {
return (
<div className={cx('flex items-center gap-2 text-micro', className)}>
<span className="text-ink-3">À pourvoir</span>
<span className="tnum font-semibold text-ink-2">
{counters.plannedLabel}
</span>
</div>
);
}
if (forfaitJours) {
return (
<div className={cx('flex items-center gap-2 text-micro', className)}>
<span className="rounded-full bg-accent-soft px-1.5 py-px font-medium text-accent-soft-ink">
Forfait jours
</span>
<Cell label="Rep." value={String(counters.restDays)} />
</div>
);
}
return (
<div
className={cx('flex flex-wrap items-center gap-x-2 gap-y-px text-micro', className)}
>
<Cell label="Contr." value={counters.contractLabel} />
<span aria-hidden className="text-line-3">
·
</span>
<Cell label="Planif." value={counters.plannedLabel} />
<span
className={cx(
'tnum rounded-full px-1.5 py-px font-semibold',
DELTA_TONES[counters.tone],
)}
title="Écart au contrat"
>
{counters.deltaLabel}
</span>
<Cell label="Dim." value={String(counters.sundaysWorked)} />
<Cell label="Rep." value={String(counters.restDays)} />
</div>
);
}
+176
View File
@@ -0,0 +1,176 @@
import { posteShort, posteTokens } from '@/lib/design/postes';
import { cx } from '@/lib/cx';
import { clock, initials, type DemoEmployee, type DemoShift } from '@/lib/demo/types';
export interface DayLane {
employee: DemoEmployee;
shifts: DemoShift[];
unassigned?: boolean;
}
export interface DayTimelineProps {
lanes: DayLane[];
/** Amplitude affichée, en heures locales. */
fromHour: number;
toHour: number;
}
/**
* Vue jour : une chronologie horizontale par salarié.
*
* Là où la vue semaine répond à « qui travaille combien », celle-ci répond à
* « qui est présent à 14 h » — d'où la bande d'effectif sous l'échelle, qui
* rend les creux de couverture visibles sans avoir à lire chaque ligne.
*/
export function DayTimeline({ lanes, fromHour, toHour }: DayTimelineProps) {
const span = toHour - fromHour;
const hours = Array.from({ length: span + 1 }, (_, i) => fromHour + i);
const coverage = hours.slice(0, -1).map((hour) => {
const start = hour * 60;
const end = start + 60;
return lanes.filter(
(lane) =>
!lane.unassigned &&
lane.shifts.some((shift) => shift.start < end && shift.end > start),
).length;
});
const peak = Math.max(1, ...coverage);
const offset = (minutes: number) =>
`${((minutes - fromHour * 60) / (span * 60)) * 100}%`;
return (
<div className="overflow-x-auto rounded-3 border border-line-1 bg-surface">
<div className="min-w-[980px]">
<div className="flex border-b border-line-2 bg-surface-2">
<div className="w-56 flex-none border-r border-line-1 px-3 py-2 text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase">
Salarié
</div>
<div className="relative flex-1">
{hours.map((hour) => (
<span
key={hour}
className="tnum absolute top-2 -translate-x-1/2 text-micro text-ink-3"
style={{ left: offset(hour * 60) }}
>
{String(hour).padStart(2, '0')}h
</span>
))}
<div className="h-9" />
</div>
</div>
<div className="flex border-b border-line-1 bg-surface-2/60">
<div className="w-56 flex-none border-r border-line-1 px-3 py-1.5 text-micro text-ink-2">
Effectif présent
</div>
<div className="flex flex-1">
{coverage.map((count, index) => (
<div
key={hours[index]}
className="flex flex-1 items-end justify-center border-r border-line-1 py-1 last:border-r-0"
title={`${count} présent${count > 1 ? 's' : ''} à ${String(hours[index]).padStart(2, '0')} h`}
>
<span
className="tnum w-full rounded-1 text-center text-micro font-semibold"
style={{
background:
count === 0
? 'var(--color-danger-soft)'
: 'var(--color-accent-soft)',
color:
count === 0
? 'var(--color-danger-soft-ink)'
: 'var(--color-accent-soft-ink)',
opacity: count === 0 ? 1 : 0.35 + (count / peak) * 0.65,
}}
>
{count}
</span>
</div>
))}
</div>
</div>
{lanes.map((lane) => (
<div
key={lane.employee.id}
className={cx(
'flex border-b border-line-1 last:border-b-0',
lane.unassigned && 'bg-surface-2',
)}
>
<div className="flex w-56 flex-none items-center gap-2 border-r border-line-1 px-3 py-2">
<span
aria-hidden
className={cx(
'flex size-6 flex-none items-center justify-center rounded-full text-micro font-semibold',
lane.unassigned
? 'border border-dashed border-line-3 text-ink-3'
: 'bg-surface-3 text-ink-2',
)}
>
{lane.unassigned ? '?' : initials(lane.employee)}
</span>
<span className="min-w-0">
<span
className={cx(
'block truncate text-sm font-medium',
lane.unassigned ? 'text-ink-3' : 'text-ink-1',
)}
>
{lane.unassigned
? 'Non assigné'
: `${lane.employee.firstName} ${lane.employee.lastName}`}
</span>
<span className="block truncate text-micro text-ink-3">
{lane.employee.job}
</span>
</span>
</div>
<div className="relative min-h-[var(--row-h)] flex-1">
{hours.slice(1, -1).map((hour) => (
<span
key={hour}
aria-hidden
className="absolute inset-y-0 w-px bg-line-1"
style={{ left: offset(hour * 60) }}
/>
))}
{lane.shifts.map((shift, index) => {
const tokens = posteTokens(shift.poste);
const ghost = shift.state === 'unassigned';
return (
<div
key={index}
className="absolute top-1 bottom-1 flex items-center gap-1.5 overflow-hidden rounded-2 px-2 text-micro"
style={{
left: offset(shift.start),
width: `calc(${offset(shift.end)} - ${offset(shift.start)})`,
background: ghost ? 'transparent' : tokens.bg,
color: ghost ? 'var(--color-ink-2)' : tokens.fg,
border: ghost
? `1px dashed ${tokens.edge}`
: '1px solid transparent',
}}
title={`${posteShort(shift.poste)} · ${clock(shift.start)}–${clock(shift.end)}`}
>
<span className="font-semibold tracking-wide">
{posteShort(shift.poste)}
</span>
<span className="tnum truncate opacity-90">
{clock(shift.start)}–{clock(shift.end)}
</span>
</div>
);
})}
</div>
</div>
))}
</div>
</div>
);
}
+103
View File
@@ -0,0 +1,103 @@
import { posteShort, posteTokens, type PosteCode } from '@/lib/design/postes';
import { cx } from '@/lib/cx';
/**
* États d'un créneau, repris du design system.
*
* `alert` ajoute un liseré danger de 1,5 px : la couleur du poste occupe déjà
* le fond, l'alerte doit donc passer par la forme, pas par une seconde teinte
* qui entrerait en concurrence avec la palette catégorielle.
*/
export type ShiftState =
| 'published'
| 'validated'
| 'draft'
| 'unpublished'
| 'alert'
| 'unassigned';
export interface ShiftChipProps {
poste: PosteCode;
/** « 09:00–17:00 ». */
time: string;
state?: ShiftState;
/** Écart affiché en pastille, ex. « +2 h 55 ». */
delta?: string;
/** Motif de l'alerte, ex. « Repos < 11 h ». */
alert?: string;
/** Durée de pause, en minutes. */
breakMinutes?: number;
className?: string;
}
export function ShiftChip({
poste,
time,
state = 'published',
delta,
alert,
breakMinutes,
className,
}: ShiftChipProps) {
const tokens = posteTokens(poste);
const ghost = state === 'unassigned';
return (
<div
className={cx(
'flex min-w-0 flex-col gap-px rounded-2 px-1.5 py-1 text-micro',
state === 'draft' && 'border-dashed',
className,
)}
style={{
background: ghost ? 'transparent' : tokens.bg,
color: ghost ? 'var(--color-ink-2)' : tokens.fg,
border:
state === 'alert'
? '1.5px solid var(--color-danger)'
: ghost
? `1px dashed ${tokens.edge}`
: state === 'draft'
? `1px dashed ${tokens.edge}`
: state === 'unpublished'
? `1px dotted ${tokens.edge}`
: '1px solid transparent',
}}
>
<span className="flex items-center gap-1">
{/* Le code du poste est le second canal : la couleur ne suffit pas. */}
<span className="font-semibold tracking-wide">{posteShort(poste)}</span>
{state === 'validated' ? (
<span className="text-ok" title="Validé" aria-label="Validé">
✓
</span>
) : null}
{delta ? (
<span className="tnum ml-auto rounded-full bg-warn-soft px-1 font-semibold text-warn-soft-ink">
{delta}
</span>
) : null}
</span>
<span className="tnum truncate">
{time}
{breakMinutes ? (
<span className="opacity-70"> ({breakMinutes} mn)</span>
) : null}
</span>
{alert ? (
// Le motif est tronqué dans une cellule étroite : le `title` garde le
// texte complet accessible, sinon l'alerte dit qu'il y a un problème
// sans jamais dire lequel.
<span
className="mt-px flex items-center gap-1 font-medium text-danger-soft-ink"
title={alert}
>
<span aria-hidden>▲</span>
<span className="truncate">{alert}</span>
</span>
) : null}
</div>
);
}
+159
View File
@@ -0,0 +1,159 @@
import { AbsenceBar } from '@/components/planning/AbsenceBar';
import { CounterStrip } from '@/components/planning/CounterStrip';
import { ShiftChip } from '@/components/planning/ShiftChip';
import { computeWeekCounters } from '@/domain/counters/week';
import { cx } from '@/lib/cx';
import {
fullName,
initials,
timeRange,
type DemoWeekRow,
} from '@/lib/demo/types';
export interface WeekGridProps {
days: readonly string[];
rows: DemoWeekRow[];
unassignedRow?: DemoWeekRow;
}
/**
* Grille hebdomadaire : lignes salariés × sept jours.
*
* Le samedi et le dimanche portent un fond distinct. Ce n'est pas décoratif :
* en commerce de détail, ce sont les jours à contreparties — dimanche du maire
* majoré et repos compensateur — et les distinguer d'un coup d'œil évite de les
* planifier par inadvertance.
*/
export function WeekGrid({ days, rows, unassignedRow }: WeekGridProps) {
return (
<div className="overflow-x-auto rounded-3 border border-line-1 bg-surface">
<div className="min-w-[1040px]">
<GridHeader days={days} />
{unassignedRow ? <Row row={unassignedRow} days={days} /> : null}
{rows.map((row) => (
<Row key={row.employee.id} row={row} days={days} />
))}
</div>
</div>
);
}
function GridHeader({ days }: { days: readonly string[] }) {
return (
<div className="sticky top-0 z-20 flex border-b border-line-2 bg-surface-2">
<div className="w-64 flex-none border-r border-line-1 px-3 py-2 text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase">
Salarié
</div>
<div className="grid flex-1 grid-cols-7">
{days.map((day, index) => (
<div
key={day}
className={cx(
'border-r border-line-1 px-2 py-2 text-center text-xs font-semibold last:border-r-0',
index > 4 ? 'bg-surface-3 text-ink-2' : 'text-ink-1',
)}
>
{day}
</div>
))}
</div>
</div>
);
}
function Row({ row, days }: { row: DemoWeekRow; days: readonly string[] }) {
const counters = computeWeekCounters(row.counters);
const name = row.unassigned ? 'Non assigné' : fullName(row.employee);
return (
<div
className={cx(
'flex border-b border-line-1 last:border-b-0',
row.unassigned && 'bg-surface-2',
)}
>
<div className="w-64 flex-none border-r border-line-1 px-3 py-2">
<div className="flex items-center gap-2">
{row.unassigned ? (
<span
aria-hidden
className="flex size-6 items-center justify-center rounded-full border border-dashed border-line-3 text-micro text-ink-3"
>
?
</span>
) : (
<span
aria-hidden
className="flex size-6 items-center justify-center rounded-full bg-surface-3 text-micro font-semibold text-ink-2"
>
{initials(row.employee)}
</span>
)}
<span className="min-w-0">
<span
className={cx(
'block truncate text-sm font-medium',
row.unassigned ? 'text-ink-3' : 'text-ink-1',
)}
>
{name}
</span>
<span className="block truncate text-micro text-ink-3">
{row.employee.job}
</span>
</span>
</div>
<CounterStrip
counters={counters}
forfaitJours={row.employee.forfaitJours ?? false}
unassigned={row.unassigned ?? false}
className="mt-1"
/>
</div>
<div className="relative grid flex-1 grid-cols-7">
{row.absence ? (
<AbsenceBar
kind={row.absence.kind}
label={row.absence.label}
duration={`${row.absence.span} j`}
startDay={row.absence.startDay}
span={row.absence.span}
className="top-1.5"
/>
) : null}
{days.map((day, index) => {
const shifts = row.days[index] ?? [];
return (
<div
key={day}
className={cx(
'flex min-h-[var(--row-h)] flex-col gap-1 border-r border-line-1 p-1 last:border-r-0',
index > 4 && 'bg-surface-2',
row.absence &&
index >= row.absence.startDay &&
index < row.absence.startDay + row.absence.span &&
'pt-7',
)}
>
{shifts.map((shift, position) => (
<ShiftChip
key={`${day}-${position}`}
poste={shift.poste}
time={timeRange(shift)}
state={shift.state ?? 'published'}
{...(shift.delta ? { delta: shift.delta } : {})}
{...(shift.alert ? { alert: shift.alert } : {})}
{...(shift.breakMinutes
? { breakMinutes: shift.breakMinutes }
: {})}
/>
))}
</div>
);
})}
</div>
</div>
);
}
+137
View File
@@ -0,0 +1,137 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import type { ReactNode } from 'react';
import { ThemeToggle } from '@/components/shell/ThemeToggle';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { cx } from '@/lib/cx';
import {
isActive,
NAVIGATION,
placeholderHref,
sectionForPath,
sectionHref,
type NavItem,
} from '@/components/shell/navigation';
export interface AppShellProps {
children: ReactNode;
}
export function AppShell({ children }: AppShellProps) {
const pathname = usePathname();
const section = sectionForPath(pathname);
return (
<div className="flex min-h-dvh flex-col bg-canvas text-ink-1">
<header className="flex h-13 flex-none items-center gap-5 border-b border-line-2 bg-surface px-5">
<Link
href="/"
className="flex items-center gap-2.5 rounded-2 text-lg font-semibold tracking-[-0.015em]"
>
<span aria-hidden className="size-5 rounded-2 bg-accent" />
PlanFlow
</Link>
<nav aria-label="Sections" className="flex h-full gap-0.5">
{NAVIGATION.map((entry) => {
const current = entry.id === section.id;
return (
<Link
key={entry.id}
href={sectionHref(entry)}
aria-current={current ? 'page' : undefined}
className={cx(
'flex h-full items-center border-b-2 px-3.5 text-sm font-medium whitespace-nowrap',
current
? 'border-accent text-ink-1'
: 'border-transparent text-ink-3 hover:text-ink-1',
)}
>
{entry.label}
</Link>
);
})}
</nav>
<span className="flex-1" />
<Button size="sm">
Nantes Atlantis
<span aria-hidden className="text-[10px] text-ink-3">
▾
</span>
</Button>
<ThemeToggle />
<span
aria-hidden
className="flex size-7 items-center justify-center rounded-full bg-accent-soft text-xs font-semibold text-accent-soft-ink"
>
MR
</span>
</header>
<div className="flex min-h-0 flex-1">
<aside className="flex w-57 flex-none flex-col gap-0.5 overflow-auto border-r border-line-1 bg-surface p-3 pt-4">
<p className="px-2.5 pb-2.5 text-micro font-semibold tracking-[0.08em] text-ink-3 uppercase">
{section.label}
</p>
{section.items.map((item) => (
<SidebarLink key={item.id} item={item} pathname={pathname} />
))}
<span className="flex-1" />
<p className="border-t border-line-1 px-2.5 pt-3 text-micro leading-relaxed text-ink-3">
Instance auto-hébergée
<br />
Maison Rivage · 34 établissements
</p>
</aside>
<main className="min-w-0 flex-1 overflow-auto">{children}</main>
</div>
</div>
);
}
function SidebarLink({ item, pathname }: { item: NavItem; pathname: string }) {
const current = item.href ? isActive(item.href, pathname) : false;
const className = cx(
'flex w-full items-center justify-between gap-2 rounded-2 px-2.5 py-1.5 text-left text-sm',
'transition-colors duration-[var(--d-1)] ease-organic',
current
? 'bg-accent-soft font-semibold text-accent-soft-ink'
: 'text-ink-2 hover:bg-surface-2',
);
const content = (
<>
<span className="truncate">{item.label}</span>
{item.badge ? (
<Badge subtle tone={current ? 'neutral' : 'neutral'}>
{item.badge}
</Badge>
) : null}
</>
);
if (!item.href) {
return (
<Link href={placeholderHref(item.label)} className={cx(className, 'text-ink-3')}>
{content}
</Link>
);
}
return (
<Link
href={item.href}
aria-current={current ? 'page' : undefined}
className={className}
>
{content}
</Link>
);
}
+28
View File
@@ -0,0 +1,28 @@
import type { ReactNode } from 'react';
export interface PageHeaderProps {
title: string;
subtitle?: string;
actions?: ReactNode;
}
export function PageHeader({ title, subtitle, actions }: PageHeaderProps) {
return (
<div className="flex flex-wrap items-end gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-[-0.018em]">{title}</h1>
{subtitle ? (
<p className="mt-1 text-sm text-ink-2">{subtitle}</p>
) : null}
</div>
<span className="flex-1" />
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
</div>
);
}
export function PageBody({ children }: { children: ReactNode }) {
return (
<div className="flex flex-col gap-5 px-6 pt-5 pb-16">{children}</div>
);
}
+82
View File
@@ -0,0 +1,82 @@
'use client';
import { useSyncExternalStore } from 'react';
import { Button } from '@/components/ui/Button';
export type Theme = 'light' | 'dark';
export const THEME_STORAGE_KEY = 'planflow.theme';
/**
* Script appliqué avant le premier rendu.
*
* Sans lui, la page s'affiche en clair puis bascule — un flash blanc en pleine
* figure pour qui travaille en sombre. Il est injecté avec le nonce de la
* requête, seule façon d'exécuter de l'inline sous notre CSP (src/proxy.ts).
*/
export const THEME_INIT_SCRIPT = `
(function () {
try {
var stored = localStorage.getItem(${JSON.stringify(THEME_STORAGE_KEY)});
var theme = stored === 'dark' || stored === 'light'
? stored
: (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', theme);
} catch (e) {
document.documentElement.setAttribute('data-theme', 'light');
}
})();
`.trim();
/**
* L'attribut `data-theme` du document est la source de vérité : il est posé par
* le script ci-dessus avant l'hydratation, donc avant que React n'existe.
* On s'y abonne plutôt que d'en tenir une copie, qui serait fausse au premier
* rendu et devrait être rattrapée après coup.
*/
function subscribe(onChange: () => void): () => void {
const observer = new MutationObserver(onChange);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme'],
});
return () => observer.disconnect();
}
function getSnapshot(): Theme {
return document.documentElement.getAttribute('data-theme') === 'dark'
? 'dark'
: 'light';
}
/** Le serveur rend toujours le thème clair, que le script corrige aussitôt. */
function getServerSnapshot(): Theme {
return 'light';
}
export function ThemeToggle() {
const theme = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
function toggle() {
const next: Theme = theme === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', next);
try {
localStorage.setItem(THEME_STORAGE_KEY, next);
} catch {
// Stockage indisponible (navigation privée) : le thème reste valable
// pour la session, ce qui vaut mieux qu'une erreur.
}
}
return (
<Button
size="sm"
className="rounded-full"
onClick={toggle}
aria-pressed={theme === 'dark'}
>
Thème {theme === 'dark' ? 'sombre' : 'clair'}
</Button>
);
}
+141
View File
@@ -0,0 +1,141 @@
/**
* Modèle de navigation, repris de l'export Claude Design.
*
* Deux niveaux : une section dans l'en-tête, ses entrées dans la barre
* latérale. Les badges portent un décompte à traiter.
*
* Les entrées sans `href` correspondent à des écrans du périmètre qui n'ont pas
* encore d'implémentation : elles restent visibles pour que la navigation
* reflète le produit réel, et mènent à un écran d'attente explicite plutôt qu'à
* une page blanche.
*/
export interface NavItem {
id: string;
label: string;
href?: string;
badge?: string;
}
export interface NavSection {
id: string;
label: string;
items: NavItem[];
}
export const NAVIGATION: NavSection[] = [
{
id: 'apercu',
label: 'Aperçu',
items: [
{ id: 'taches', label: 'Tâches RH', href: '/' },
{ id: 'entrees', label: 'Entrées et sorties' },
{ id: 'profils', label: 'Profils incomplets', badge: '6' },
{ id: 'soldes-cp', label: 'Compteurs de congés payés' },
],
},
{
id: 'plannings',
label: 'Plannings',
items: [
{ id: 'semaine', label: 'Vue semaine', href: '/planning/semaine' },
{ id: 'jour', label: 'Vue jour', href: '/planning/jour' },
{ id: 'besoins', label: 'Besoins non couverts', badge: '5' },
{ id: 'modeles', label: "Modèles d'ouverture" },
],
},
{
id: 'equipe',
label: 'Équipe',
items: [
{ id: 'membres', label: 'Membres', href: '/equipe' },
{ id: 'fiche', label: 'Fiche salarié', href: '/equipe/camille-ferrand' },
{ id: 'contrats', label: 'Modifications de contrat' },
],
},
{
id: 'conges',
label: 'Congés',
items: [
{ id: 'calendrier', label: 'Calendrier des absences', href: '/conges' },
{ id: 'attente', label: 'Demandes en attente', href: '/conges#attente', badge: '3' },
{ id: 'politiques', label: 'Politiques de congés' },
],
},
{
id: 'rapports',
label: 'Rapports',
items: [
{ id: 'heures', label: 'Heures travaillées' },
{ id: 'paie', label: 'Préparation de paie' },
{ id: 'activite', label: "Journal d'activité" },
],
},
{
id: 'reglages',
label: 'Réglages',
items: [
{ id: 'convention', label: 'Convention collective' },
{ id: 'postes', label: 'Postes et étiquettes' },
{ id: 'sites', label: 'Établissements' },
{ id: 'roles', label: 'Rôles et permissions' },
],
},
];
/** Section contenant la route donnée, ou la première par défaut. */
export function sectionForPath(pathname: string): NavSection {
const active = activeItem(pathname);
const match = active
? NAVIGATION.find((section) =>
section.items.some((item) => item.id === active.id),
)
: undefined;
return match ?? (NAVIGATION[0] as NavSection);
}
/** Chemin vers l'écran d'attente d'une entrée non encore construite. */
export function placeholderHref(label: string): string {
return `/bientot?ecran=${encodeURIComponent(label)}`;
}
/**
* Destination d'un onglet de section.
*
* Une section dont aucun écran n'est construit ne doit pas renvoyer à
* l'accueil : le clic donnerait l'impression que l'onglet est cassé. Elle mène
* à l'écran d'attente de sa première entrée, qui dit ce qui viendra là.
*/
export function sectionHref(section: NavSection): string {
const built = section.items.find((item) => item.href);
if (built?.href) return built.href;
const first = section.items[0];
return first ? placeholderHref(first.label) : '/';
}
export function matches(href: string, pathname: string): boolean {
// Un lien d'ancre pointe à l'intérieur d'une page déjà représentée par une
// autre entrée : il ne concourt pas.
if (href.includes('#')) return false;
if (href === '/') return pathname === '/';
return pathname === href || pathname.startsWith(`${href}/`);
}
/**
* Entrée à mettre en évidence pour une route donnée.
*
* `/equipe` et `/equipe/camille-ferrand` correspondent tous deux quand on est
* sur une fiche. On retient la **plus spécifique** : deux entrées allumées à la
* fois, et le repère de position ne repère plus rien.
*/
export function activeItem(pathname: string): NavItem | undefined {
return NAVIGATION.flatMap((section) => section.items)
.filter((item) => item.href && matches(item.href, pathname))
.sort((a, b) => (b.href as string).length - (a.href as string).length)[0];
}
export function isActive(href: string, pathname: string): boolean {
const active = activeItem(pathname);
return active?.href === href;
}
+43
View File
@@ -0,0 +1,43 @@
import type { ReactNode } from 'react';
import { cx } from '@/lib/cx';
export type Tone = 'neutral' | 'accent' | 'ok' | 'warn' | 'danger' | 'info';
const TONES: Record<Tone, string> = {
neutral: 'bg-surface-3 text-ink-2 border-line-2',
accent: 'bg-accent-soft text-accent-soft-ink border-accent',
ok: 'bg-ok-soft text-ok-soft-ink border-ok',
warn: 'bg-warn-soft text-warn-soft-ink border-warn',
danger: 'bg-danger-soft text-danger-soft-ink border-danger',
info: 'bg-info-soft text-info-soft-ink border-info',
};
export interface BadgeProps {
tone?: Tone;
/** Sans liseré : pour les compteurs discrets de la navigation. */
subtle?: boolean;
children: ReactNode;
className?: string;
}
/** Pastille compacte. Porte un état ou un décompte, jamais une action. */
export function Badge({
tone = 'neutral',
subtle = false,
children,
className,
}: BadgeProps) {
return (
<span
className={cx(
'inline-flex shrink-0 items-center rounded-full px-2 py-px text-micro font-semibold',
subtle ? 'border-transparent' : 'border',
TONES[tone],
className,
)}
>
{children}
</span>
);
}
+51
View File
@@ -0,0 +1,51 @@
import type { ButtonHTMLAttributes, ReactNode } from 'react';
import { cx } from '@/lib/cx';
export type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger';
export type ButtonSize = 'sm' | 'md';
const VARIANTS: Record<ButtonVariant, string> = {
primary:
'border-accent bg-accent text-accent-ink hover:bg-accent-hover active:bg-accent-press',
secondary:
'border-line-3 bg-surface text-ink-1 hover:bg-surface-2 active:bg-surface-3',
ghost:
'border-transparent bg-transparent text-ink-2 hover:bg-surface-2 hover:text-ink-1',
danger:
'border-danger bg-danger text-danger-ink hover:bg-danger-hover active:bg-danger-hover',
};
const SIZES: Record<ButtonSize, string> = {
sm: 'h-[26px] px-2.5 text-xs',
md: 'h-8 px-3.5 text-sm',
};
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
children: ReactNode;
}
export function Button({
variant = 'secondary',
size = 'md',
className,
type = 'button',
...rest
}: ButtonProps) {
return (
<button
type={type}
className={cx(
'inline-flex shrink-0 items-center justify-center gap-1.5 rounded-2 border font-medium',
'cursor-pointer transition-colors duration-[var(--d-1)] ease-organic',
'disabled:cursor-not-allowed disabled:opacity-50',
VARIANTS[variant],
SIZES[size],
className,
)}
{...rest}
/>
);
}
+67
View File
@@ -0,0 +1,67 @@
import type { ReactNode } from 'react';
import { cx } from '@/lib/cx';
export interface CardProps {
children: ReactNode;
className?: string;
}
export function Card({ children, className }: CardProps) {
return (
<section
className={cx(
'overflow-hidden rounded-3 border border-line-1 bg-surface',
className,
)}
>
{children}
</section>
);
}
export interface CardHeaderProps {
title: ReactNode;
/** Décompte ou statut affiché contre le titre. */
badge?: ReactNode;
action?: ReactNode;
}
export function CardHeader({ title, badge, action }: CardHeaderProps) {
return (
<header className="flex items-center gap-2.5 border-b border-line-1 px-4 py-3">
<h2 className="text-md font-semibold">{title}</h2>
{badge}
<span className="flex-1" />
{action}
</header>
);
}
export interface EmptyStateProps {
title: string;
description?: string;
className?: string;
}
export function EmptyState({ title, description, className }: EmptyStateProps) {
return (
<div
className={cx(
'flex flex-col items-center gap-2.5 px-6 py-16 text-center',
className,
)}
>
<span
aria-hidden
className="size-9 rounded-2 border-[1.5px] border-line-3 bg-surface-2"
/>
<p className="text-md font-semibold">{title}</p>
{description ? (
<p className="max-w-[46ch] text-sm leading-[var(--lh-prose)] text-ink-2">
{description}
</p>
) : null}
</div>
);
}
+91
View File
@@ -0,0 +1,91 @@
/**
* Compteurs hebdomadaires d'un salarié.
*
* PLAN.md §7.4 : une seule implémentation alimente la grille, le rapport
* d'heures et l'export de paie. Trois calculs qui divergent sont la première
* cause d'écart entre un planning et un bulletin — et l'écart ne se voit qu'au
* moment où le comptable le réclame.
*
* Les durées sont manipulées en **minutes entières**. Les heures décimales
* accumulent des erreurs d'arrondi qui finissent par déplacer un compteur d'une
* minute, ce qui est invisible à l'écran et faux en paie.
*/
export interface WeekCounters {
/** Durée contractuelle hebdomadaire, en minutes. */
contractMinutes: number;
/** Somme des créneaux planifiés, pauses déduites. */
plannedMinutes: number;
/** Durée couverte par des absences. */
absenceMinutes: number;
/** Nombre de dimanches travaillés dans la semaine. */
sundaysWorked: number;
/** Jours de repos dans la semaine. */
restDays: number;
}
export interface WeekCountersView extends WeekCounters {
/** Planifié + absences − contrat. Signé. */
deltaMinutes: number;
contractLabel: string;
plannedLabel: string;
absenceLabel: string;
deltaLabel: string;
tone: DeltaTone;
}
export type DeltaTone = 'flat' | 'over' | 'under' | 'none';
/**
* Un salarié au forfait jours n'a pas de durée hebdomadaire : le comparer à un
* contrat en heures produirait un écart qui n'a aucun sens (PLAN.md §6.4).
*/
export const FORFAIT_JOURS = Symbol('forfait-jours');
export function formatMinutes(total: number): string {
const sign = total < 0 ? '−' : '';
const abs = Math.abs(total);
const hours = Math.floor(abs / 60);
const minutes = abs % 60;
return `${sign}${hours} h ${String(minutes).padStart(2, '0')}`;
}
/** Écart signé : le « + » explicite évite de confondre 0 h 00 et un manque. */
export function formatDelta(total: number): string {
if (total === 0) return '+0 h 00';
return total > 0 ? `+${formatMinutes(total)}` : formatMinutes(total);
}
export function deltaTone(deltaMinutes: number): DeltaTone {
if (deltaMinutes === 0) return 'flat';
return deltaMinutes > 0 ? 'over' : 'under';
}
export function computeWeekCounters(input: WeekCounters): WeekCountersView {
// Les absences comptent dans l'atteinte du contrat : un salarié en congés une
// semaine entière ne doit pas apparaître en sous-réalisation de 35 h.
const deltaMinutes =
input.plannedMinutes + input.absenceMinutes - input.contractMinutes;
return {
...input,
deltaMinutes,
contractLabel: formatMinutes(input.contractMinutes),
plannedLabel: formatMinutes(input.plannedMinutes),
absenceLabel: formatMinutes(input.absenceMinutes),
deltaLabel: formatDelta(deltaMinutes),
tone: deltaTone(deltaMinutes),
};
}
/** Durée d'un créneau, pauses déduites, à partir de deux instants. */
export function shiftMinutes(
start: Date,
end: Date,
breakMinutes = 0,
): number {
// Calcul depuis les instants, jamais depuis l'heure murale : la nuit du
// changement d'heure, 22 h–06 h dure 7 h ou 9 h, jamais 8 h (PLAN.md §3.3).
const elapsed = Math.round((end.getTime() - start.getTime()) / 60_000);
return Math.max(0, elapsed - breakMinutes);
}
+4
View File
@@ -0,0 +1,4 @@
/** Concatène des classes en ignorant les valeurs vides. */
export function cx(...parts: Array<string | false | null | undefined>): string {
return parts.filter(Boolean).join(' ');
}
+16
View File
@@ -0,0 +1,16 @@
# Données de démonstration
**Entièrement fictives.** Aucune donnée réelle de salarié ne doit entrer ici :
ce répertoire est versionné, lisible par tous, et n'offre aucune des garanties
que `PLAN.md` §3.6 exige pour un dossier RH — chiffrement au repos,
journalisation des accès, durées de conservation.
Ce module existe parce que les écrans ont été construits avant WP-01 (tenancy,
identité, base de données). Il tient la forme des données pour que la mise en
page soit réelle plutôt que devinée.
**Remplacement.** Chaque écran importe depuis `@/lib/demo/...` et rien d'autre.
Au fil des lots, ces imports sont remplacés un à un par des requêtes serveur
scopées. Le jour où le répertoire disparaît, il ne reste rien à nettoyer
ailleurs : c'est la seule raison pour laquelle il est isolé plutôt que dispersé
dans les composants.
+103
View File
@@ -0,0 +1,103 @@
import type { Tone } from '@/components/ui/Badge';
export interface DemoTile {
label: string;
value: string;
tone: Tone;
}
export const TILES: DemoTile[] = [
{ label: 'Profils incomplets', value: '6', tone: 'warn' },
{ label: 'Fins de période d’essai', value: '2', tone: 'info' },
{ label: 'Documents à signer', value: '11', tone: 'accent' },
{ label: 'Titres de séjour à renouveler', value: '1', tone: 'danger' },
];
export interface DemoEntry {
initials: string;
name: string;
job: string;
date: string;
state: string;
tone: Tone;
}
export const ENTRIES: DemoEntry[] = [
{
initials: 'YB',
name: 'Yanis Bouchard',
job: 'Vendeur univers maison · CDI 35 h',
date: '17 août',
state: 'Dossier complet',
tone: 'ok',
},
{
initials: 'MR',
name: 'Maëlys Renard',
job: 'Hôtesse de caisse · CDD 24 h',
date: '24 août',
state: '2 pièces manquantes',
tone: 'warn',
},
{
initials: 'TN',
name: 'Théo Nguyen',
job: 'Employé réserve · CDI 35 h',
date: '1 sept.',
state: 'Contrat à signer',
tone: 'info',
},
{
initials: 'SL',
name: 'Sarah Lemoine',
job: 'Vendeuse décoration · CDI 30 h',
date: '7 sept.',
state: 'Dossier complet',
tone: 'ok',
},
];
export interface DemoTodo {
dot: string;
title: string;
detail: string;
when: string;
}
export const TODOS: DemoTodo[] = [
{
dot: 'var(--color-danger)',
title: '3 alertes de convention sur la semaine 33',
detail:
'Repos quotidien inférieur à 11 h pour Rémi Chartier (mardi) et amplitude dépassée pour Inès Bakhti (samedi).',
when: 'Aujourd’hui',
},
{
dot: 'var(--color-warn)',
title: 'Semaine 34 non publiée',
detail:
'La publication doit intervenir au moins 7 jours avant le premier créneau, soit avant le 10 août 18:00.',
when: 'J−0',
},
{
dot: 'var(--color-info)',
title: '3 demandes de congés en attente',
detail:
'Nour Zaidi (5 jours), Rémi Chartier (2 jours), Alix Perron (1 jour).',
when: '2 j',
},
{
dot: 'var(--color-ink-3)',
title: '11 bulletins de paie à diffuser',
detail:
'Période de juillet 2026, contrôlée et validée par le service paie le 5 août.',
when: '5 j',
},
{
dot: 'var(--color-ink-3)',
title: 'Compteurs de récupération à solder',
detail:
'4 salariés dépassent 12 h de récupération non prise depuis plus de deux mois.',
when: '12 j',
},
];
+58
View File
@@ -0,0 +1,58 @@
import type { AbsenceKind } from '@/components/planning/AbsenceBar';
export interface DemoCalendarAbsence {
who: string;
type: string;
kind: AbsenceKind;
/** Jours du mois, inclusifs. */
from: number;
to: number;
}
export const MONTH_LABEL = 'Août 2026';
/** Le 1er août 2026 tombe un samedi : la grille démarre le lundi 27 juillet. */
export const MONTH_OFFSET = 5;
export const MONTH_LENGTH = 31;
export const CALENDAR_ABSENCES: DemoCalendarAbsence[] = [
{ who: 'Nour Zaidi', type: 'CP', kind: 'cp', from: 10, to: 14 },
{ who: 'Malik Ouedraogo', type: 'Maladie', kind: 'maladie', from: 13, to: 14 },
{ who: 'Rémi Chartier', type: 'RTT', kind: 'rtt', from: 18, to: 19 },
{ who: 'Elsa Tavares', type: 'CP', kind: 'cp', from: 24, to: 28 },
{ who: 'Inès Bakhti', type: 'Sans solde', kind: 'sans-solde', from: 21, to: 21 },
{ who: 'Alix Perron', type: 'Maladie', kind: 'maladie', from: 5, to: 6 },
{ who: 'Chloé Vasseur', type: 'CP', kind: 'cp', from: 26, to: 28 },
];
export interface DemoPendingRequest {
who: string;
type: string;
range: string;
days: string;
note: string;
}
export const PENDING_REQUESTS: DemoPendingRequest[] = [
{
who: 'Nour Zaidi',
type: 'Congés payés',
range: '10 – 14 août',
days: '5 j',
note: 'Solde restant après validation : 9,5 j',
},
{
who: 'Rémi Chartier',
type: 'RTT',
range: '18 – 19 août',
days: '2 j',
note: 'Chevauche un jour de forte affluence',
},
{
who: 'Alix Perron',
type: 'Sans solde',
range: '31 août',
days: '1 j',
note: 'Aucune contrainte de planning',
},
];
+130
View File
@@ -0,0 +1,130 @@
import type { DemoEmployee } from '@/lib/demo/types';
/** Effectif fictif de l'établissement Nantes Atlantis. */
export const EMPLOYEES: DemoEmployee[] = [
{
id: 'camille-ferrand',
firstName: 'Camille',
lastName: 'Ferrand',
job: 'Responsable univers maison',
poste: 'enc',
contract: 'CDI 39 h',
since: '4 mars 2021',
status: 'actif',
},
{
id: 'jonas-meyer',
firstName: 'Jonas',
lastName: 'Meyer',
job: 'Adjoint magasin',
poste: 'enc',
contract: 'CDI · forfait jours',
since: '12 sept. 2019',
status: 'actif',
forfaitJours: true,
},
{
id: 'remi-chartier',
firstName: 'Rémi',
lastName: 'Chartier',
job: 'Vendeur',
poste: 'vte',
contract: 'CDI 35 h',
since: '2 juin 2022',
status: 'actif',
},
{
id: 'ines-bakhti',
firstName: 'Inès',
lastName: 'Bakhti',
job: 'Hôtesse de caisse',
poste: 'cai',
contract: 'CDI 35 h',
since: '18 janv. 2023',
status: 'actif',
},
{
id: 'nour-zaidi',
firstName: 'Nour',
lastName: 'Zaidi',
job: 'Vendeuse décoration',
poste: 'vte',
contract: 'CDI 30 h',
since: '5 oct. 2020',
status: 'actif',
},
{
id: 'alix-perron',
firstName: 'Alix',
lastName: 'Perron',
job: 'Employé réserve',
poste: 'res',
contract: 'CDI 35 h',
since: '9 mai 2024',
status: 'actif',
},
{
id: 'elsa-tavares',
firstName: 'Elsa',
lastName: 'Tavares',
job: 'Vendeuse',
poste: 'vte',
contract: 'CDD 24 h',
since: '3 févr. 2026',
status: 'essai',
},
{
id: 'malik-ouedraogo',
firstName: 'Malik',
lastName: 'Ouedraogo',
job: 'Employé logistique',
poste: 'liv',
contract: 'CDI 35 h',
since: '21 nov. 2018',
status: 'actif',
},
{
id: 'chloe-vasseur',
firstName: 'Chloé',
lastName: 'Vasseur',
job: 'Vendeuse',
poste: 'vte',
contract: 'CDI 20 h',
since: '14 juil. 2025',
status: 'actif',
},
{
id: 'yanis-bouchard',
firstName: 'Yanis',
lastName: 'Bouchard',
job: 'Vendeur univers maison',
poste: 'vte',
contract: 'CDI 35 h',
since: '17 août 2026',
status: 'essai',
},
{
id: 'sarah-lemoine',
firstName: 'Sarah',
lastName: 'Lemoine',
job: 'Vendeuse décoration',
poste: 'vte',
contract: 'CDI 30 h',
since: '7 sept. 2026',
status: 'essai',
},
{
id: 'hugo-delaunay',
firstName: 'Hugo',
lastName: 'Delaunay',
job: 'Employé retrait web',
poste: 'clc',
contract: 'CDI 28 h',
since: '2 avr. 2023',
status: 'sortie',
},
];
export function findEmployee(id: string): DemoEmployee | undefined {
return EMPLOYEES.find((employee) => employee.id === id);
}
+96
View File
@@ -0,0 +1,96 @@
import type { Tone } from '@/components/ui/Badge';
import type { PosteCode } from '@/lib/design/postes';
export interface FicheShift {
day: string;
poste: PosteCode;
time: string;
worked: string;
state: string;
tone: Tone;
}
export const FICHE_SHIFTS: FicheShift[] = [
{
day: 'Lun. 10 août',
poste: 'enc',
time: '08:00–16:00',
worked: '8 h 00',
state: 'Validé',
tone: 'ok',
},
{
day: 'Mar. 11 août',
poste: 'enc',
time: '08:00–16:00',
worked: '8 h 00',
state: 'Validé',
tone: 'ok',
},
{
day: 'Mer. 12 août',
poste: 'for',
time: '09:00–12:30',
worked: '3 h 30',
state: 'Validé',
tone: 'ok',
},
{
day: 'Mer. 12 août',
poste: 'enc',
time: '13:30–17:00',
worked: '3 h 30',
state: 'Heures réelles manquantes',
tone: 'warn',
},
{
day: 'Jeu. 13 août',
poste: 'enc',
time: '11:00–19:00',
worked: '8 h 00',
state: 'Publié',
tone: 'neutral',
},
];
export interface FicheDocument {
label: string;
date: string;
state: string;
tone: Tone;
}
export const FICHE_DOCUMENTS: FicheDocument[] = [
{
label: 'Contrat de travail — avenant temps partiel',
date: '2 janv. 2026',
state: 'Signé',
tone: 'ok',
},
{
label: 'Entretien professionnel 2026',
date: '18 mai 2026',
state: 'Signé',
tone: 'ok',
},
{
label: 'Attestation mutuelle',
date: '—',
state: 'En attente',
tone: 'warn',
},
];
export interface FicheCounter {
label: string;
value: string;
tone?: Tone;
}
export const FICHE_COUNTERS: FicheCounter[] = [
{ label: 'Contrat', value: '39 h' },
{ label: 'Planifié S33', value: '39 h 00' },
{ label: 'Écart', value: '+0 h 00' },
{ label: 'CP acquis', value: '14,5 j' },
{ label: 'Récupération', value: '4 h 30', tone: 'warn' },
];
+268
View File
@@ -0,0 +1,268 @@
import { findEmployee } from '@/lib/demo/equipe';
import type { DemoEmployee, DemoShift, DemoWeekRow } from '@/lib/demo/types';
const h = (hours: number, minutes = 0) => hours * 60 + minutes;
function pick(id: string): DemoEmployee {
const employee = findEmployee(id);
if (!employee) throw new Error(`Salarié de démonstration inconnu : ${id}`);
return employee;
}
const NONE: DemoShift[] = [];
export const WEEK_DAYS = [
'Lun. 10',
'Mar. 11',
'Mer. 12',
'Jeu. 13',
'Ven. 14',
'Sam. 15',
'Dim. 16',
] as const;
export const WEEK_LABEL = 'Semaine 33 · 10 – 16 août 2026';
export const WEEK_ROWS: DemoWeekRow[] = [
{
employee: pick('camille-ferrand'),
counters: {
contractMinutes: h(39),
plannedMinutes: h(39),
absenceMinutes: 0,
sundaysWorked: 1,
restDays: 2,
},
days: [
[{ poste: 'enc', start: h(8), end: h(16), state: 'validated' }],
[{ poste: 'enc', start: h(8), end: h(16), state: 'validated' }],
[
{ poste: 'for', start: h(9), end: h(12, 30), state: 'validated' },
{ poste: 'enc', start: h(13, 30), end: h(17), state: 'validated' },
],
[{ poste: 'enc', start: h(11), end: h(19), state: 'validated' }],
[{ poste: 'enc', start: h(11), end: h(19), state: 'validated' }],
NONE,
NONE,
],
},
{
employee: pick('jonas-meyer'),
counters: {
contractMinutes: 0,
plannedMinutes: h(38, 30),
absenceMinutes: 0,
sundaysWorked: 1,
restDays: 2,
},
days: [
[{ poste: 'enc', start: h(12), end: h(20), state: 'validated' }],
[{ poste: 'enc', start: h(12), end: h(20), state: 'validated' }],
[
{ poste: 'inv', start: h(6, 30), end: h(11), state: 'validated' },
{ poste: 'enc', start: h(11, 30), end: h(15), state: 'validated' },
],
[{ poste: 'enc', start: h(12), end: h(20), state: 'validated' }],
NONE,
[{ poste: 'enc', start: h(9), end: h(19), state: 'draft' }],
NONE,
],
},
{
employee: pick('remi-chartier'),
counters: {
contractMinutes: h(35),
plannedMinutes: h(36, 30),
absenceMinutes: 0,
sundaysWorked: 2,
restDays: 2,
},
days: [
[
{ poste: 'vte', start: h(9, 30), end: h(13), state: 'validated' },
{ poste: 'cai', start: h(14), end: h(18, 30), state: 'validated' },
],
[
{
poste: 'cai',
start: h(12),
end: h(20),
state: 'alert',
alert: 'Repos < 11 h',
},
],
[{ poste: 'vte', start: h(10), end: h(14), state: 'validated' }],
NONE,
[
{ poste: 'vte', start: h(9), end: h(13), state: 'validated' },
{ poste: 'cab', start: h(14), end: h(18), state: 'validated' },
],
[{ poste: 'cai', start: h(9, 30), end: h(19, 30), state: 'draft' }],
NONE,
],
},
{
employee: pick('ines-bakhti'),
counters: {
contractMinutes: h(35),
plannedMinutes: h(35),
absenceMinutes: 0,
sundaysWorked: 1,
restDays: 2,
},
days: [
[{ poste: 'cai', start: h(9), end: h(17), state: 'validated' }],
[{ poste: 'cai', start: h(9), end: h(17), state: 'validated' }],
NONE,
[{ poste: 'cai', start: h(11, 30), end: h(19, 30), state: 'validated' }],
[
{ poste: 'cai', start: h(9), end: h(14), state: 'validated' },
{ poste: 'clc', start: h(14, 30), end: h(17, 30), state: 'validated' },
],
[
{
poste: 'cai',
start: h(9, 30),
end: h(19, 30),
state: 'alert',
alert: 'Amplitude > 10 h',
},
],
NONE,
],
},
{
employee: pick('nour-zaidi'),
counters: {
contractMinutes: h(30),
plannedMinutes: h(12),
absenceMinutes: h(18),
sundaysWorked: 0,
restDays: 5,
},
absence: { kind: 'cp', label: 'Congés payés', startDay: 0, span: 5 },
days: [
NONE,
NONE,
NONE,
NONE,
NONE,
[{ poste: 'vte', start: h(13), end: h(19), state: 'draft' }],
[{ poste: 'vte', start: h(11), end: h(17), state: 'unpublished' }],
],
},
{
employee: pick('alix-perron'),
counters: {
contractMinutes: h(35),
plannedMinutes: h(33, 15),
absenceMinutes: 0,
sundaysWorked: 0,
restDays: 2,
},
days: [
[
{ poste: 'liv', start: h(6), end: h(11), state: 'validated' },
{ poste: 'res', start: h(11, 30), end: h(14, 45), state: 'validated' },
],
[{ poste: 'res', start: h(6), end: h(13), state: 'validated' }],
[{ poste: 'liv', start: h(6), end: h(11), state: 'validated' }],
NONE,
[{ poste: 'res', start: h(6), end: h(13), state: 'validated' }],
[{ poste: 'rss', start: h(7), end: h(13), state: 'draft' }],
NONE,
],
},
{
employee: pick('elsa-tavares'),
counters: {
contractMinutes: h(24),
plannedMinutes: h(25, 30),
absenceMinutes: 0,
sundaysWorked: 2,
restDays: 3,
},
days: [
NONE,
NONE,
[{ poste: 'vte', start: h(14), end: h(19), state: 'validated' }],
[{ poste: 'cab', start: h(14), end: h(19), state: 'validated' }],
[{ poste: 'vte', start: h(14), end: h(19, 30), state: 'validated' }],
[{ poste: 'cai', start: h(13, 30), end: h(19, 30), state: 'draft' }],
[{ poste: 'vte', start: h(10), end: h(14), state: 'unpublished' }],
],
},
{
employee: pick('malik-ouedraogo'),
counters: {
contractMinutes: h(35),
plannedMinutes: h(31),
absenceMinutes: h(14),
sundaysWorked: 0,
restDays: 2,
},
absence: { kind: 'maladie', label: 'Arrêt maladie', startDay: 3, span: 2 },
days: [
[
{ poste: 'liv', start: h(5, 30), end: h(10, 30), state: 'validated' },
{ poste: 'res', start: h(11), end: h(14), state: 'validated' },
],
[{ poste: 'rss', start: h(6), end: h(13, 30), state: 'validated' }],
[{ poste: 'inv', start: h(6), end: h(13), state: 'validated' }],
NONE,
NONE,
[{ poste: 'liv', start: h(6), end: h(12, 30), state: 'draft' }],
NONE,
],
},
{
employee: pick('chloe-vasseur'),
counters: {
contractMinutes: h(20),
plannedMinutes: h(21, 15),
absenceMinutes: 0,
sundaysWorked: 2,
restDays: 4,
},
days: [
NONE,
NONE,
[{ poste: 'for', start: h(9), end: h(13), state: 'validated' }],
NONE,
[{ poste: 'vte', start: h(15), end: h(19, 30), state: 'validated' }],
[{ poste: 'cai', start: h(11), end: h(18), state: 'draft' }],
[{ poste: 'vte', start: h(11), end: h(15), state: 'unpublished' }],
],
},
];
/** Ligne des besoins sans titulaire, affichée en tête de grille. */
export const UNASSIGNED_ROW: DemoWeekRow = {
employee: {
id: 'non-assigne',
firstName: '',
lastName: 'Non assigné',
job: 'Besoin non couvert',
poste: 'cai',
contract: '—',
since: '—',
status: 'actif',
},
unassigned: true,
counters: {
contractMinutes: 0,
plannedMinutes: h(14),
absenceMinutes: 0,
sundaysWorked: 0,
restDays: 0,
},
days: [
NONE,
[{ poste: 'net', start: h(20), end: h(22), state: 'unassigned' }],
NONE,
[{ poste: 'clc', start: h(16), end: h(20), state: 'unassigned' }],
[{ poste: 'cai', start: h(17), end: h(20), state: 'unassigned' }],
[{ poste: 'cai', start: h(14), end: h(19), state: 'unassigned' }],
[{ poste: 'clc', start: h(13), end: h(17), state: 'unassigned' }],
],
};
+63
View File
@@ -0,0 +1,63 @@
import type { AbsenceKind } from '@/components/planning/AbsenceBar';
import type { ShiftState } from '@/components/planning/ShiftChip';
import type { WeekCounters } from '@/domain/counters/week';
import type { PosteCode } from '@/lib/design/postes';
export interface DemoShift {
poste: PosteCode;
/** Début et fin en minutes depuis minuit, jour local. */
start: number;
end: number;
breakMinutes?: number;
state?: ShiftState;
delta?: string;
alert?: string;
}
export interface DemoAbsence {
kind: AbsenceKind;
label: string;
/** 0 = lundi. */
startDay: number;
span: number;
}
export interface DemoEmployee {
id: string;
firstName: string;
lastName: string;
job: string;
poste: PosteCode;
contract: string;
since: string;
status: 'actif' | 'essai' | 'sortie';
forfaitJours?: boolean;
}
export interface DemoWeekRow {
employee: DemoEmployee;
counters: WeekCounters;
/** Sept entrées, lundi → dimanche. */
days: DemoShift[][];
absence?: DemoAbsence;
unassigned?: boolean;
}
export function fullName(employee: DemoEmployee): string {
return `${employee.firstName} ${employee.lastName}`;
}
export function initials(employee: DemoEmployee): string {
return `${employee.firstName.charAt(0)}${employee.lastName.charAt(0)}`;
}
/** « 09:00 » depuis des minutes après minuit. */
export function clock(minutes: number): string {
const h = Math.floor(minutes / 60);
const m = minutes % 60;
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
}
export function timeRange(shift: DemoShift): string {
return `${clock(shift.start)}–${clock(shift.end)}`;
}
+95
View File
@@ -0,0 +1,95 @@
/**
* Palette catégorielle des postes.
*
* Douze teintes dérivées d'une formule commune (voir globals.css) : clarté et
* chroma partagés, seule la teinte varie, plus un palier alterné qui écarte les
* voisines. C'est ce qui les garde distinguables en vision daltonienne.
*
* Règle non négociable : **la couleur ne porte jamais l'information seule**.
* Tout bloc coloré affiche aussi le code du poste. Un planning se lit aussi en
* noir et blanc, à l'impression, et par quelqu'un qui confond le rouge et le
* vert — trois cas où une pastille de couleur seule ne dit plus rien.
*/
export const POSTE_CODES = [
'cai',
'vte',
'res',
'acc',
'cab',
'rss',
'inv',
'liv',
'enc',
'clc',
'net',
'for',
] as const;
export type PosteCode = (typeof POSTE_CODES)[number];
export const POSTE_LABELS: Record<PosteCode, string> = {
cai: 'Caisse',
vte: 'Vente conseil',
res: 'Réserve',
acc: 'Accueil',
cab: 'Cabines',
rss: 'Réassort',
inv: 'Inventaire',
liv: 'Livraison',
enc: 'Encadrement',
clc: 'Retrait web',
net: 'Entretien',
for: 'Formation',
};
/** Teinte oklch de chaque poste, pour la documentation et les tests. */
export const POSTE_HUES: Record<PosteCode, number> = {
cai: 250,
vte: 162,
res: 30,
acc: 205,
cab: 100,
rss: 308,
inv: 138,
liv: 52,
enc: 276,
clc: 340,
net: 185,
for: 68,
};
export interface PosteTokens {
/** Aplat de fond. */
bg: string;
/** Encre lisible sur cet aplat. */
fg: string;
/** Liseré, conservé même quand le fond est vide (créneau non assigné). */
edge: string;
}
/**
* Jetons CSS d'un poste. Renvoie des `var(...)` plutôt que des couleurs
* calculées : le thème sombre redéfinit les variables, donc la même expression
* suit le thème sans que le composant ait à le savoir.
*/
export function posteTokens(code: PosteCode): PosteTokens {
return {
bg: `var(--post-${code}-bg)`,
fg: `var(--post-${code}-fg)`,
edge: `var(--post-${code}-edge)`,
};
}
export function posteLabel(code: PosteCode): string {
return POSTE_LABELS[code];
}
/** Code affiché dans le bloc — le second canal, en plus de la couleur. */
export function posteShort(code: PosteCode): string {
return code.toUpperCase();
}
export function isPosteCode(value: string): value is PosteCode {
return (POSTE_CODES as readonly string[]).includes(value);
}
+45
View File
@@ -0,0 +1,45 @@
import { z } from 'zod';
/**
* Environment contract, validated once at import.
*
* A missing DATABASE_URL should fail at boot with a readable message, not
* surface later as an opaque driver error in the middle of a payroll export.
*/
const schema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
DATABASE_URL: z.url({ protocol: /^postgres(ql)?$/ }),
/**
* Application-level encryption key for the columns PLAN.md §3.6 requires at
* rest (NIR, IBAN, BIC). 32 bytes, base64. Kept out of the database so a dump
* alone does not disclose them.
*/
ENCRYPTION_KEY: z
.string()
.refine(
(value) => Buffer.from(value, 'base64').length === 32,
'ENCRYPTION_KEY doit être 32 octets encodés en base64',
),
/** Public origin, used for links in invitation and notification e-mails. */
APP_URL: z.url().default('http://localhost:3000'),
});
export type Env = z.infer<typeof schema>;
function load(): Env {
const parsed = schema.safeParse(process.env);
if (!parsed.success) {
const details = parsed.error.issues
.map((issue) => ` - ${issue.path.join('.')}: ${issue.message}`)
.join('\n');
throw new Error(`Configuration d'environnement invalide :\n${details}`);
}
return parsed.data;
}
export const env = load();
+67
View File
@@ -0,0 +1,67 @@
/**
* Content-Security-Policy for PlanFlow.
*
* PLAN.md §3.7 bans third-party analytics and advertising trackers. The audit of
* the reference product intercepted 2102 such requests and no business call at
* all; an HR application must not leak employee-context navigation to ad
* networks. This policy names no external origin, so any dependency that tries
* is stopped by the browser.
*
* Kept as a pure function so the rule is unit-testable without booting Next.
* Applied per request in src/proxy.ts.
*/
export interface CspOptions {
/** Per-request nonce, base64. Next.js needs it for its own inline scripts. */
nonce: string;
/** Dev needs 'unsafe-eval' for React Refresh. Never enable it in production. */
isDevelopment?: boolean;
}
/** Directives that must never name a host other than 'self'. */
export const NETWORK_DIRECTIVES = [
'default-src',
'script-src',
'connect-src',
'img-src',
'style-src',
'font-src',
'frame-src',
] as const;
export function buildContentSecurityPolicy({
nonce,
isDevelopment = false,
}: CspOptions): string {
const scriptSrc = [
"'self'",
`'nonce-${nonce}'`,
// Lets modern browsers trust scripts loaded by a nonce-verified script,
// while older ones fall back to 'self'.
"'strict-dynamic'",
...(isDevelopment ? ["'unsafe-eval'"] : []),
];
const directives: Record<string, string[]> = {
'default-src': ["'self'"],
'script-src': scriptSrc,
// Tailwind injects styles at build time, but React still sets inline
// style attributes; 'unsafe-inline' for styles carries no script risk.
'style-src': ["'self'", "'unsafe-inline'"],
'img-src': ["'self'", 'blob:', 'data:'],
'font-src': ["'self'"],
'connect-src': ["'self'"],
'frame-src': ["'none'"],
'object-src': ["'none'"],
'base-uri': ["'self'"],
'form-action': ["'self'"],
'frame-ancestors': ["'none'"],
'upgrade-insecure-requests': [],
};
return Object.entries(directives)
.map(([directive, values]) =>
values.length > 0 ? `${directive} ${values.join(' ')}` : directive,
)
.join('; ');
}
+43
View File
@@ -0,0 +1,43 @@
import { NextResponse, type NextRequest } from 'next/server';
import { buildContentSecurityPolicy } from '@/lib/security/csp';
/**
* Emits a per-request nonce and the Content-Security-Policy built from it.
*
* PLAN.md §3.7 forbids third-party trackers. A policy that names no external
* origin is what makes that rule enforceable rather than merely stated: a
* dependency that starts phoning home is blocked by the browser instead of
* quietly succeeding.
*
* Next.js 16 renamed the `middleware` convention to `proxy`.
*/
export function proxy(request: NextRequest): NextResponse {
const nonce = Buffer.from(crypto.randomUUID()).toString('base64');
const csp = buildContentSecurityPolicy({
nonce,
isDevelopment: process.env.NODE_ENV === 'development',
});
// Server Components read the nonce from the request headers.
const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-nonce', nonce);
requestHeaders.set('content-security-policy', csp);
const response = NextResponse.next({ request: { headers: requestHeaders } });
response.headers.set('content-security-policy', csp);
return response;
}
export const config = {
matcher: [
// Everything except static assets, which need no policy of their own.
{
source: '/((?!_next/static|_next/image|favicon.ico).*)',
missing: [
{ type: 'header', key: 'next-router-prefetch' },
{ type: 'header', key: 'purpose', value: 'prefetch' },
],
},
],
};
+31
View File
@@ -0,0 +1,31 @@
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '@prisma/client';
import { env } from '@/lib/env';
/**
* Prisma client singleton.
*
* WP-01 wraps this with the tenant-scoping extension required by PLAN.md §3.1,
* so every query is filtered by the session's account and scope, with
* PostgreSQL row-level security behind it as defence in depth. Nothing outside
* this module should construct a client.
*/
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
function createClient(): PrismaClient {
const adapter = new PrismaPg({ connectionString: env.DATABASE_URL });
return new PrismaClient({
adapter,
log: env.NODE_ENV === 'development' ? ['warn', 'error'] : ['error'],
});
}
export const prisma = globalForPrisma.prisma ?? createClient();
if (env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma;
}
+19
View File
@@ -0,0 +1,19 @@
import { prisma } from '@/server/db';
export interface HealthResult {
ok: boolean;
error?: string;
}
/** Round-trips a trivial query so the landing page reports real connectivity. */
export async function checkDatabase(): Promise<HealthResult> {
try {
await prisma.$queryRaw`SELECT 1`;
return { ok: true };
} catch (error) {
return {
ok: false,
error: error instanceof Error ? error.message : 'erreur inconnue',
};
}
}
+60
View File
@@ -0,0 +1,60 @@
import { expect, test } from '@playwright/test';
test('les six écrans se chargent et affichent leur contenu', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('heading', { name: 'Aperçu RH' })).toBeVisible();
await page.getByRole('link', { name: 'Plannings' }).click();
await expect(
page.getByRole('heading', { name: /Planning · semaine 33/ }),
).toBeVisible();
// La grille doit porter des créneaux, pas seulement son ossature.
await expect(page.getByText('Congés payés').first()).toBeVisible();
await page.getByRole('link', { name: 'Vue jour' }).click();
await expect(
page.getByRole('heading', { name: /Planning · mercredi 12 août/ }),
).toBeVisible();
await page.getByRole('link', { name: 'Équipe', exact: true }).click();
await expect(page.getByRole('heading', { name: 'Équipe' })).toBeVisible();
await page.getByRole('link', { name: 'Camille Ferrand' }).click();
await expect(
page.getByRole('heading', { name: 'Camille Ferrand' }),
).toBeVisible();
await page.getByRole('link', { name: 'Congés' }).click();
await expect(
page.getByRole('heading', { name: 'Calendrier des absences' }),
).toBeVisible();
});
test('le thème bascule et survit à un rechargement', async ({ page }) => {
await page.goto('/');
const html = page.locator('html');
await expect(html).toHaveAttribute('data-theme', 'light');
await page.getByRole('button', { name: /^Thème/ }).click();
await expect(html).toHaveAttribute('data-theme', 'dark');
// Le script inline doit reposer le thème avant le premier rendu : sans lui,
// la page reviendrait en clair puis basculerait — un flash blanc.
await page.reload();
await expect(html).toHaveAttribute('data-theme', 'dark');
});
test('une entrée de navigation non construite mène à un écran explicite', async ({
page,
}) => {
await page.goto('/');
await page.getByRole('link', { name: 'Profils incomplets' }).click();
await expect(
page.getByRole('heading', { name: 'Profils incomplets' }),
).toBeVisible();
// `exact` évite de heurter l'annonceur de route de Next, qui répète le titre
// du document — « Écran à venir · PlanFlow ».
await expect(
page.getByText('Écran à venir', { exact: true }),
).toBeVisible();
});
+30
View File
@@ -0,0 +1,30 @@
import { expect, test } from '@playwright/test';
/**
* The unit tests prove the policy builder is correct. This proves the running
* application actually sends it — a middleware matcher that quietly stops
* matching would pass every unit test and ship an unprotected app.
*/
test('les en-têtes de sécurité sont servis par l’application', async ({
request,
}) => {
const response = await request.get('/');
expect(response.status()).toBe(200);
const headers = response.headers();
const csp = headers['content-security-policy'];
expect(csp, 'aucune Content-Security-Policy servie').toBeTruthy();
expect(csp).toContain("default-src 'self'");
expect(csp).toContain("frame-ancestors 'none'");
expect(csp).toMatch(/script-src [^;]*'nonce-/);
expect(headers['x-frame-options']).toBe('DENY');
expect(headers['x-content-type-options']).toBe('nosniff');
expect(headers['referrer-policy']).toBe('no-referrer');
expect(headers['permissions-policy']).toContain('geolocation=()');
// Next.js advertises itself by default; there is no reason to tell an
// attacker which framework and version to look up.
expect(headers['x-powered-by']).toBeUndefined();
});
+114
View File
@@ -0,0 +1,114 @@
import { describe, expect, it } from 'vitest';
import {
computeWeekCounters,
deltaTone,
formatDelta,
formatMinutes,
shiftMinutes,
} from '@/domain/counters/week';
const h = (hours: number, minutes = 0) => hours * 60 + minutes;
describe('formatMinutes', () => {
it('formate en heures et minutes sur deux chiffres', () => {
expect(formatMinutes(h(35))).toBe('35 h 00');
expect(formatMinutes(h(8, 5))).toBe('8 h 05');
expect(formatMinutes(0)).toBe('0 h 00');
});
it('utilise le signe moins typographique pour les négatifs', () => {
expect(formatMinutes(-h(1, 30))).toBe('−1 h 30');
});
});
describe('formatDelta', () => {
it('distingue un écart nul d’un manque', () => {
// « 0 h 00 » seul se lit comme une absence de donnée ; le « + » dit que le
// contrat est atteint exactement.
expect(formatDelta(0)).toBe('+0 h 00');
expect(formatDelta(h(2, 55))).toBe('+2 h 55');
expect(formatDelta(-h(0, 5))).toBe('−0 h 05');
});
});
describe('deltaTone', () => {
it('classe le dépassement, le manque et l’exact', () => {
expect(deltaTone(0)).toBe('flat');
expect(deltaTone(1)).toBe('over');
expect(deltaTone(-1)).toBe('under');
});
});
describe('computeWeekCounters', () => {
it('compte les absences dans l’atteinte du contrat', () => {
// Une semaine entière de congés ne doit pas apparaître en manque de 30 h :
// le salarié n'a pas sous-travaillé, il était absent.
const counters = computeWeekCounters({
contractMinutes: h(30),
plannedMinutes: h(12),
absenceMinutes: h(18),
sundaysWorked: 0,
restDays: 5,
});
expect(counters.deltaMinutes).toBe(0);
expect(counters.deltaLabel).toBe('+0 h 00');
expect(counters.tone).toBe('flat');
});
it('signale un dépassement du contrat', () => {
const counters = computeWeekCounters({
contractMinutes: h(35),
plannedMinutes: h(36, 30),
absenceMinutes: 0,
sundaysWorked: 2,
restDays: 2,
});
expect(counters.deltaLabel).toBe('+1 h 30');
expect(counters.tone).toBe('over');
});
it('signale un contrat non atteint', () => {
const counters = computeWeekCounters({
contractMinutes: h(35),
plannedMinutes: h(33, 15),
absenceMinutes: 0,
sundaysWorked: 0,
restDays: 2,
});
expect(counters.deltaLabel).toBe('−1 h 45');
expect(counters.tone).toBe('under');
});
});
describe('shiftMinutes', () => {
it('déduit la pause', () => {
const start = new Date('2026-08-10T09:00:00+02:00');
const end = new Date('2026-08-10T17:00:00+02:00');
expect(shiftMinutes(start, end, 30)).toBe(h(7, 30));
});
it('mesure la durée réelle la nuit du passage à l’heure d’hiver', () => {
// 25 octobre 2026, 03:00 → 02:00 : la nuit dure une heure de plus.
// Un calcul sur l'heure murale dirait 8 h ; l'instant dit 9 h.
const start = new Date('2026-10-24T22:00:00+02:00');
const end = new Date('2026-10-25T06:00:00+01:00');
expect(shiftMinutes(start, end)).toBe(h(9));
});
it('mesure la durée réelle la nuit du passage à l’heure d’été', () => {
// 29 mars 2026, 02:00 → 03:00 : la nuit dure une heure de moins.
const start = new Date('2026-03-28T22:00:00+01:00');
const end = new Date('2026-03-29T06:00:00+02:00');
expect(shiftMinutes(start, end)).toBe(h(7));
});
it('ne renvoie jamais de durée négative', () => {
const start = new Date('2026-08-10T09:00:00+02:00');
const end = new Date('2026-08-10T09:10:00+02:00');
expect(shiftMinutes(start, end, 60)).toBe(0);
});
});
+64
View File
@@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest';
import {
buildContentSecurityPolicy,
NETWORK_DIRECTIVES,
} from '@/lib/security/csp';
function parse(policy: string): Map<string, string[]> {
return new Map(
policy.split('; ').map((directive) => {
const [name, ...values] = directive.split(' ');
return [name ?? '', values];
}),
);
}
/** Any source that is neither a keyword, a nonce, nor a safe scheme. */
function externalOrigins(values: string[]): string[] {
return values.filter((value) => {
if (value.startsWith("'")) return false; // 'self', 'none', 'nonce-…', …
if (value === 'blob:' || value === 'data:') return false;
return true;
});
}
describe('Content-Security-Policy', () => {
const policy = buildContentSecurityPolicy({ nonce: 'dGVzdC1ub25jZQ==' });
const directives = parse(policy);
it('names no external origin on any network directive', () => {
// PLAN.md §3.7 — the audited product shipped Segment, LinkedIn Ads, Google
// Ads, DoubleClick, Clarity, Hotjar and Bugsnag. This assertion is what
// keeps that from creeping back in as a "small" addition.
for (const directive of NETWORK_DIRECTIVES) {
const values = directives.get(directive) ?? [];
expect(
externalOrigins(values),
`${directive} autorise une origine tierce`,
).toEqual([]);
}
});
it('carries the request nonce on script-src', () => {
expect(directives.get('script-src')).toContain("'nonce-dGVzdC1ub25jZQ=='");
});
it('never allows unsafe-eval outside development', () => {
expect(policy).not.toContain("'unsafe-eval'");
});
it('allows unsafe-eval in development only, for React Refresh', () => {
const devPolicy = buildContentSecurityPolicy({
nonce: 'dGVzdA==',
isDevelopment: true,
});
expect(devPolicy).toContain("'unsafe-eval'");
});
it('forbids inline scripts, framing and object embedding', () => {
expect(directives.get('script-src')).not.toContain("'unsafe-inline'");
expect(directives.get('frame-ancestors')).toEqual(["'none'"]);
expect(directives.get('object-src')).toEqual(["'none'"]);
});
});
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it, vi } from 'vitest';
/**
* The env module validates at import, so each case needs a fresh module
* registry with process.env set beforehand.
*/
async function loadEnv(values: Record<string, string | undefined>) {
const previous = { ...process.env };
process.env = { ...previous, ...values } as NodeJS.ProcessEnv;
try {
vi.resetModules();
return await import('@/lib/env');
} finally {
process.env = previous;
}
}
const VALID_KEY = Buffer.alloc(32, 7).toString('base64');
describe('configuration d’environnement', () => {
it('accepte une configuration complète', async () => {
const { env } = await loadEnv({
DATABASE_URL: 'postgresql://user:pass@localhost:5432/planflow',
ENCRYPTION_KEY: VALID_KEY,
APP_URL: 'https://planflow.example',
});
expect(env.DATABASE_URL).toContain('planflow');
expect(env.APP_URL).toBe('https://planflow.example');
});
it('refuse une clé de chiffrement qui ne fait pas 32 octets', async () => {
await expect(
loadEnv({
DATABASE_URL: 'postgresql://user:pass@localhost:5432/planflow',
ENCRYPTION_KEY: Buffer.alloc(16, 1).toString('base64'),
}),
).rejects.toThrow(/ENCRYPTION_KEY/);
});
it('refuse une URL de base non PostgreSQL', async () => {
await expect(
loadEnv({
DATABASE_URL: 'mysql://user:pass@localhost:3306/planflow',
ENCRYPTION_KEY: VALID_KEY,
}),
).rejects.toThrow(/DATABASE_URL/);
});
});
+69
View File
@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest';
import {
activeItem,
isActive,
matches,
NAVIGATION,
sectionForPath,
} from '@/components/shell/navigation';
describe('matches', () => {
it('reconnaît la racine sans capturer tout le site', () => {
expect(matches('/', '/')).toBe(true);
expect(matches('/', '/equipe')).toBe(false);
});
it('couvre les sous-routes', () => {
expect(matches('/equipe', '/equipe')).toBe(true);
expect(matches('/equipe', '/equipe/camille-ferrand')).toBe(true);
expect(matches('/equipe', '/equipements')).toBe(false);
});
it('écarte les liens d’ancre', () => {
expect(matches('/conges#attente', '/conges')).toBe(false);
});
});
describe('activeItem', () => {
it('retient la correspondance la plus spécifique', () => {
// Sur une fiche, « Membres » (/equipe) et « Fiche salarié » correspondent
// tous deux ; c'est la fiche qui doit s'allumer.
expect(activeItem('/equipe/camille-ferrand')?.id).toBe('fiche');
expect(activeItem('/equipe')?.id).toBe('membres');
});
it('ne renvoie rien pour une route hors navigation', () => {
expect(activeItem('/inconnu')).toBeUndefined();
});
});
describe('isActive', () => {
it('n’allume qu’une entrée par écran', () => {
const targets = NAVIGATION.flatMap((section) => section.items)
.map((item) => item.href)
.filter(
(href): href is string =>
typeof href === 'string' && !href.includes('#'),
);
for (const pathname of [...targets, '/equipe/sarah-lemoine']) {
const lit = NAVIGATION.flatMap((section) => section.items).filter(
(item) => item.href && isActive(item.href, pathname),
);
expect(lit.length, `${pathname} allume ${lit.length} entrées`).toBe(1);
}
});
});
describe('sectionForPath', () => {
it('trouve la section portant la route courante', () => {
expect(sectionForPath('/planning/semaine').id).toBe('plannings');
expect(sectionForPath('/equipe/camille-ferrand').id).toBe('equipe');
expect(sectionForPath('/conges').id).toBe('conges');
});
it('retombe sur la première section pour une route inconnue', () => {
expect(sectionForPath('/inconnu').id).toBe('apercu');
});
});
+65
View File
@@ -0,0 +1,65 @@
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
/**
* PLAN.md §3.7 bans analytics and advertising dependencies outright.
*
* The CSP stops such a package at runtime; this stops it at review time, with a
* message that says why. The audited product carried ten of these, so the
* failure mode is not hypothetical — it is what happens when nobody is looking.
*/
const BANNED = [
'segment',
'@segment/',
'analytics-node',
'react-ga',
'gtag',
'google-analytics',
'mixpanel',
'amplitude',
'hotjar',
'clarity-js',
'satismeter',
'fullstory',
'logrocket',
'bugsnag',
'sentry',
'datadog',
'posthog',
'heap-analytics',
'intercom',
];
interface PackageJson {
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
}
describe('dépendances', () => {
const packageJson: PackageJson = JSON.parse(
readFileSync(
fileURLToPath(new URL('../../package.json', import.meta.url)),
'utf8',
),
);
const installed = [
...Object.keys(packageJson.dependencies ?? {}),
...Object.keys(packageJson.devDependencies ?? {}),
];
it('ne contient aucun traceur publicitaire ou analytique', () => {
const offenders = installed.filter((name) =>
BANNED.some((banned) => name.toLowerCase().includes(banned)),
);
expect(
offenders,
'PLAN.md §3.7 interdit les traceurs tiers dans une application RH. ' +
"Pour de la télémétrie technique, passer par l'interface abstraite " +
'auto-hébergée plutôt que par un service externe.',
).toEqual([]);
});
});
+93
View File
@@ -0,0 +1,93 @@
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import {
isPosteCode,
POSTE_CODES,
POSTE_HUES,
POSTE_LABELS,
posteShort,
posteTokens,
} from '@/lib/design/postes';
const CSS = readFileSync(
fileURLToPath(new URL('../../src/app/globals.css', import.meta.url)),
'utf8',
);
describe('palette des postes', () => {
it('déclare douze postes distincts', () => {
expect(POSTE_CODES).toHaveLength(12);
expect(new Set(POSTE_CODES).size).toBe(12);
});
it('donne un libellé à chaque poste', () => {
for (const code of POSTE_CODES) {
expect(POSTE_LABELS[code], `libellé manquant pour ${code}`).toBeTruthy();
}
});
it('sépare suffisamment les teintes voisines', () => {
// Deux postes séparés de quelques degrés seraient indiscernables à petite
// taille, ce qui rendrait la grille fausse à la lecture.
const hues = [...Object.values(POSTE_HUES)].sort((a, b) => a - b);
for (let i = 1; i < hues.length; i += 1) {
const gap = (hues[i] as number) - (hues[i - 1] as number);
expect(gap, `teintes trop proches : ${hues[i - 1]} et ${hues[i]}`).toBeGreaterThanOrEqual(12);
}
});
it('définit les trois jetons CSS de chaque poste', () => {
for (const code of POSTE_CODES) {
for (const suffix of ['bg', 'fg', 'edge']) {
expect(
CSS.includes(`--post-${code}-${suffix}:`),
`jeton --post-${code}-${suffix} absent de globals.css`,
).toBe(true);
}
}
});
it('renvoie des variables CSS, pour que le thème sombre suive', () => {
const tokens = posteTokens('cai');
expect(tokens.bg).toBe('var(--post-cai-bg)');
expect(tokens.fg).toBe('var(--post-cai-fg)');
expect(tokens.edge).toBe('var(--post-cai-edge)');
});
it('fournit un code court affichable — le second canal de lecture', () => {
// La couleur ne porte jamais l'information seule : un planning se lit aussi
// en niveaux de gris et par quelqu'un qui confond le rouge et le vert.
expect(posteShort('vte')).toBe('VTE');
for (const code of POSTE_CODES) {
expect(posteShort(code)).toHaveLength(3);
}
});
it('reconnaît les codes valides', () => {
expect(isPosteCode('cai')).toBe(true);
expect(isPosteCode('zzz')).toBe(false);
});
});
describe('jetons de thème', () => {
it('redéfinit les couleurs pour le thème sombre', () => {
expect(CSS).toContain(":root[data-theme='dark']");
for (const token of ['--color-canvas', '--color-ink-1', '--color-accent']) {
const occurrences = CSS.split(token).length - 1;
expect(
occurrences,
`${token} doit être défini en clair et redéfini en sombre`,
).toBeGreaterThanOrEqual(2);
}
});
it('inverse le sens du palier de clarté en sombre', () => {
// Sans cette inversion, le palier qui écarte les teintes voisines pousse du
// mauvais côté et les rapproche au lieu de les séparer.
expect(CSS).toContain('--tier-dir: -1');
expect(CSS).toContain('--tier-dir: 1');
});
});
+49
View File
@@ -0,0 +1,49 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": [
"dom",
"dom.iterable",
"ES2022"
],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"exactOptionalPropertyTypes": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"verbatimModuleSyntax": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./src/*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules",
".next",
"tests/e2e"
]
}
+16
View File
@@ -0,0 +1,16 @@
import { fileURLToPath } from 'node:url';
import { defineConfig } from 'vitest/config';
export default defineConfig({
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
test: {
environment: 'node',
include: ['tests/unit/**/*.test.ts'],
globals: false,
},
});