Merge pull request #7 — socle applicatif et implémentation du design
Socle applicatif et implémentation du design
This commit is contained in:
67 files changed
+11099
-1
No files matched your search
@@ -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
|
||||
@@ -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
@@ -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
@@ -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"]
|
||||
@@ -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).
|
||||
|
||||
@@ -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é. |
|
||||
@@ -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.
|
||||
@@ -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:
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Generated
+6462
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,8 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -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";
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
}
|
||||
Whitespace-only changes.
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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' },
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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(' ');
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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',
|
||||
},
|
||||
];
|
||||
@@ -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',
|
||||
},
|
||||
];
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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' },
|
||||
];
|
||||
@@ -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' }],
|
||||
],
|
||||
};
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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('; ');
|
||||
}
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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'"]);
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user