mirror of
https://github.com/R0m1k3/LogiFlow.git
synced 2026-10-24 15:38:18 +02:00
Compare commits
19
Commits
3f2660c506
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0afbecffa | ||
|
|
4ed129bd74 | ||
|
|
c869d7aa08 | ||
|
|
97d63b2097 | ||
|
|
af6a854400 | ||
|
|
bffdca1297 | ||
|
|
600627c632 | ||
|
|
6760f43e87 | ||
|
|
b964e137bf | ||
|
|
bd3bb5e64a | ||
|
|
6f1a39e4b2 | ||
|
|
2274c9bb4a | ||
|
|
944d99733c | ||
|
|
8a27b310d2 | ||
|
|
6bb61cdbcc | ||
|
|
d384271a30 | ||
|
|
f4c582b217 | ||
|
|
5d3e5357a0 | ||
|
|
a5785f0244 |
No files matched your search
@@ -94,3 +94,10 @@ ssl/
|
|||||||
# Backup files
|
# Backup files
|
||||||
*.bak
|
*.bak
|
||||||
*.backup
|
*.backup
|
||||||
|
# Compilation Rust locale (le binaire est construit dans l'image)
|
||||||
|
server-rs/target/
|
||||||
|
|
||||||
|
# Serveur Node de référence du banc (scripts/bench/reference-node.sh)
|
||||||
|
.bench-reference/
|
||||||
|
# Interface construite en local : reconstruite dans l'image
|
||||||
|
dist/
|
||||||
@@ -30,3 +30,9 @@ ENCRYPTION_KEY=
|
|||||||
# Route de secours POST /api/emergency-admin-reset : désactivée si vide.
|
# Route de secours POST /api/emergency-admin-reset : désactivée si vide.
|
||||||
# Ne définir qu'en cas de besoin, puis retirer.
|
# Ne définir qu'en cas de besoin, puis retirer.
|
||||||
EMERGENCY_SECRET=
|
EMERGENCY_SECRET=
|
||||||
|
|
||||||
|
# API externe de rapprochement BL / factures (docs/API-RAPPROCHEMENT.md).
|
||||||
|
# Les clés se créent de préférence dans Paramètres > API externe. Optionnel :
|
||||||
|
# une ou plusieurs clés supplémentaires ici, séparées par des virgules.
|
||||||
|
# Générer une clé : node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||||
|
EXTERNAL_API_KEYS=
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Décision R6 (docs/PLAN-MIGRATION-RUST.md) : le serveur Rust, le banc qui le
|
||||||
|
# vérifie et les fichiers dont il dépend sont relus par leur mainteneur.
|
||||||
|
/server-rs/ @R0m1k3
|
||||||
|
/scripts/bench/ @R0m1k3
|
||||||
|
/scripts/gen-rust-schema.ts @R0m1k3
|
||||||
|
/shared/schema.ts @R0m1k3
|
||||||
|
/shared/permissions.json @R0m1k3
|
||||||
|
/Dockerfile @R0m1k3
|
||||||
|
/docker-compose.yml @R0m1k3
|
||||||
|
/.github/workflows/ @R0m1k3
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
name: Serveur Rust
|
||||||
|
|
||||||
|
# Contrôles du serveur Rust, contrôle de type et construction de l'interface, et
|
||||||
|
# banc de comparaison avec le serveur Node de référence (scripts/bench/README.md)
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ main, master ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ main, master ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
checks:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: server-rs
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
components: rustfmt, clippy
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
workspaces: server-rs
|
||||||
|
- name: Mise en forme
|
||||||
|
run: cargo fmt --check
|
||||||
|
- name: Clippy
|
||||||
|
run: cargo clippy --all-targets --locked -- -D warnings
|
||||||
|
- name: Tests
|
||||||
|
run: cargo test --locked
|
||||||
|
|
||||||
|
client:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: npm
|
||||||
|
- name: Dépendances
|
||||||
|
run: npm ci
|
||||||
|
- name: Contrôle de type
|
||||||
|
run: npm run check
|
||||||
|
- name: Construction de l'interface
|
||||||
|
run: npx vite build
|
||||||
|
- name: Fichier docker-compose valide
|
||||||
|
run: docker compose -f docker-compose.yml config -q
|
||||||
|
|
||||||
|
bench:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: logiflow_admin
|
||||||
|
POSTGRES_PASSWORD: bench
|
||||||
|
POSTGRES_DB: postgres
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
options: >-
|
||||||
|
--health-cmd "pg_isready -U logiflow_admin"
|
||||||
|
--health-interval 5s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 20
|
||||||
|
env:
|
||||||
|
BENCH_PG_URL: postgres://logiflow_admin:bench@localhost:5432
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
# Le serveur Node de référence est construit depuis un commit passé
|
||||||
|
fetch-depth: 0
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: npm
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
workspaces: server-rs
|
||||||
|
- name: Dépendances
|
||||||
|
run: npm ci
|
||||||
|
- name: Serveur Node de référence (REFERENCE_NODE_COMMIT)
|
||||||
|
run: scripts/bench/reference-node.sh
|
||||||
|
- name: Serveur Rust
|
||||||
|
run: cargo build --release --locked --manifest-path server-rs/Cargo.toml
|
||||||
|
- name: Base modèle du banc
|
||||||
|
run: scripts/bench/setup.sh
|
||||||
|
- name: Banc Node de référence contre Rust
|
||||||
|
run: npx tsx scripts/bench/compare.ts
|
||||||
+3
-1
@@ -1,7 +1,6 @@
|
|||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
.DS_Store
|
.DS_Store
|
||||||
server/public
|
|
||||||
vite.config.ts.*
|
vite.config.ts.*
|
||||||
*.tar.gz
|
*.tar.gz
|
||||||
|
|
||||||
@@ -13,3 +12,6 @@ cookie*.txt
|
|||||||
# Artefacts de debug locaux
|
# Artefacts de debug locaux
|
||||||
*.log
|
*.log
|
||||||
scratch/
|
scratch/
|
||||||
|
|
||||||
|
# Serveur Node de référence du banc (scripts/bench/reference-node.sh)
|
||||||
|
.bench-reference/
|
||||||
+34
-104
@@ -1,119 +1,49 @@
|
|||||||
# Multi-stage build for optimized production image
|
# Image de production : interface construite par Vite + serveur Rust (server-rs/).
|
||||||
FROM node:20-alpine AS build
|
# Node ne sert qu'à construire l'interface ; il n'est pas dans l'image finale.
|
||||||
|
|
||||||
# Set working directory
|
# Interface (dist/public)
|
||||||
|
FROM node:20-alpine AS client-build
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy package files
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
# Install all dependencies (including dev dependencies needed for build)
|
|
||||||
RUN npm ci && npm cache clean --force
|
RUN npm ci && npm cache clean --force
|
||||||
|
COPY tsconfig.json vite.config.ts tailwind.config.ts postcss.config.js components.json ./
|
||||||
# Copy source code (excluding .env to avoid conflicts)
|
|
||||||
COPY package*.json ./
|
|
||||||
COPY tsconfig.json ./
|
|
||||||
COPY drizzle.config.ts ./
|
|
||||||
COPY vite.config.ts ./
|
|
||||||
COPY tailwind.config.ts ./
|
|
||||||
COPY postcss.config.js ./
|
|
||||||
COPY components.json ./
|
|
||||||
COPY client/ client/
|
COPY client/ client/
|
||||||
COPY server/ server/
|
|
||||||
COPY shared/ shared/
|
COPY shared/ shared/
|
||||||
|
RUN NODE_ENV=production npx vite build && test -f dist/public/index.html
|
||||||
|
|
||||||
# Vérifier la structure
|
# Serveur : binaire statique (musl) pour l'image Alpine
|
||||||
RUN ls -la server/
|
FROM rust:1.97-alpine AS rust-build
|
||||||
|
RUN apk add --no-cache musl-dev
|
||||||
|
WORKDIR /build
|
||||||
|
# Dépendances d'abord : couche en cache tant que Cargo.lock ne change pas
|
||||||
|
COPY server-rs/Cargo.toml server-rs/Cargo.lock ./server-rs/
|
||||||
|
RUN mkdir -p server-rs/src && echo "fn main() {}" > server-rs/src/main.rs && \
|
||||||
|
cargo build --release --locked --manifest-path server-rs/Cargo.toml && \
|
||||||
|
rm -rf server-rs/src
|
||||||
|
COPY server-rs/build.rs ./server-rs/build.rs
|
||||||
|
COPY server-rs/src ./server-rs/src
|
||||||
|
COPY shared/permissions.json ./shared/permissions.json
|
||||||
|
RUN touch server-rs/src/main.rs && \
|
||||||
|
cargo build --release --locked --manifest-path server-rs/Cargo.toml
|
||||||
|
|
||||||
# Build the application
|
FROM alpine:3.20 AS production
|
||||||
# Build frontend first
|
# pg_dump/psql pour les sauvegardes, wget pour la sonde de santé
|
||||||
RUN NODE_ENV=production npx vite build
|
RUN apk add --no-cache postgresql-client wget tzdata ca-certificates && \
|
||||||
|
addgroup -g 1001 -S logiflow && adduser -S logiflow -u 1001 -G logiflow
|
||||||
# Vérifier que les fichiers sont construits
|
|
||||||
RUN echo "=== BUILD VERIFICATION ===" && \
|
|
||||||
ls -la dist/ && \
|
|
||||||
echo "Frontend files in dist/public:" && \
|
|
||||||
ls -la dist/public/ && \
|
|
||||||
echo "index.html exists:" && \
|
|
||||||
ls -la dist/public/index.html
|
|
||||||
|
|
||||||
# Build backend avec tous les modules externes
|
|
||||||
RUN npx esbuild server/index.production.ts \
|
|
||||||
--platform=node \
|
|
||||||
--bundle \
|
|
||||||
--format=esm \
|
|
||||||
--outfile=dist/index.js \
|
|
||||||
--external:vite \
|
|
||||||
--external:@vitejs/* \
|
|
||||||
--external:@replit/* \
|
|
||||||
--external:tsx \
|
|
||||||
--external:openid-client \
|
|
||||||
--external:@neondatabase/serverless \
|
|
||||||
--external:ws \
|
|
||||||
--external:drizzle-orm \
|
|
||||||
--external:pg \
|
|
||||||
--external:express \
|
|
||||||
--external:connect-pg-simple \
|
|
||||||
--external:passport \
|
|
||||||
--external:passport-local \
|
|
||||||
--external:express-session \
|
|
||||||
--external:zod \
|
|
||||||
--external:express-rate-limit \
|
|
||||||
--external:memoizee \
|
|
||||||
--external:nanoid \
|
|
||||||
--external:date-fns \
|
|
||||||
--external:nodemailer
|
|
||||||
|
|
||||||
# Production stage
|
|
||||||
FROM node:20-alpine AS production
|
|
||||||
|
|
||||||
# Create non-root user for security
|
|
||||||
RUN addgroup -g 1001 -S nodejs && \
|
|
||||||
adduser -S nextjs -u 1001
|
|
||||||
|
|
||||||
# Set working directory
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
COPY --from=rust-build /build/server-rs/target/release/logiflow /app/bin/logiflow
|
||||||
|
COPY --from=client-build /app/dist/public /app/dist/public
|
||||||
|
COPY migrations/ /app/migrations/
|
||||||
|
RUN mkdir -p /app/backups && chown -R logiflow:logiflow /app/backups
|
||||||
|
USER logiflow
|
||||||
|
|
||||||
# Install production dependencies only
|
ENV PORT=3000 \
|
||||||
COPY --from=build /app/package*.json ./
|
STATIC_DIR=/app/dist/public \
|
||||||
RUN npm ci --only=production && npm cache clean --force
|
BACKUP_DIR=/app/backups \
|
||||||
|
NODE_ENV=production
|
||||||
# Copy built application from build stage
|
|
||||||
COPY --from=build --chown=nextjs:nodejs /app/dist ./dist
|
|
||||||
COPY --from=build --chown=nextjs:nodejs /app/shared ./shared
|
|
||||||
|
|
||||||
# Copy migration scripts and files
|
|
||||||
COPY --chown=nextjs:nodejs scripts/ ./scripts/
|
|
||||||
COPY --chown=nextjs:nodejs migrations/ ./migrations/
|
|
||||||
COPY --chown=nextjs:nodejs scripts/docker-entrypoint.sh ./scripts/docker-entrypoint.sh
|
|
||||||
|
|
||||||
# Create symlink for public files to be accessible from dist
|
|
||||||
RUN ln -sf /app/dist/public /app/dist/public
|
|
||||||
|
|
||||||
# Create uploads and backups directories with proper permissions
|
|
||||||
RUN mkdir -p /app/uploads /app/backups && chown -R nextjs:nodejs /app/uploads /app/backups
|
|
||||||
|
|
||||||
# Switch to non-root user
|
|
||||||
USER nextjs
|
|
||||||
|
|
||||||
# Expose port
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
ENV PORT=3000
|
|
||||||
|
|
||||||
# Install wget for health check and postgresql-client for pg_dump
|
|
||||||
USER root
|
|
||||||
RUN apk add --no-cache wget postgresql-client
|
|
||||||
USER nextjs
|
|
||||||
|
|
||||||
# Health check
|
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1
|
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1
|
||||||
|
|
||||||
# Make scripts executable
|
CMD ["/app/bin/logiflow"]
|
||||||
RUN chmod +x /app/scripts/auto-migrate-production.sh && \
|
|
||||||
chmod +x /app/scripts/docker-entrypoint.sh && \
|
|
||||||
ls -la /app/scripts/ && \
|
|
||||||
test -f /app/scripts/docker-entrypoint.sh || echo "ERREUR: docker-entrypoint.sh manquant"
|
|
||||||
|
|
||||||
# Apply DLC migration directly and start application
|
|
||||||
CMD ["sh", "-c", "echo 'Applying DLC migration...'; if [ -n \"$DATABASE_URL\" ]; then psql \"$DATABASE_URL\" -c 'ALTER TABLE dlc_products ADD COLUMN IF NOT EXISTS stock_epuise boolean DEFAULT false NOT NULL, ADD COLUMN IF NOT EXISTS stock_epuise_by varchar(255), ADD COLUMN IF NOT EXISTS stock_epuise_at timestamp; CREATE INDEX IF NOT EXISTS idx_dlc_products_stock_epuise ON dlc_products(stock_epuise);' || echo 'Migration already applied or failed'; fi; node dist/index.js"]
|
|
||||||
@@ -44,8 +44,8 @@ Une plateforme d'entreprise moderne pour la gestion logistique, le suivi des liv
|
|||||||
## 🚀 Installation et Configuration
|
## 🚀 Installation et Configuration
|
||||||
|
|
||||||
### Prérequis
|
### Prérequis
|
||||||
- Node.js 18+
|
- Rust stable (serveur) et Node.js 20+ (construction de l'interface uniquement)
|
||||||
- PostgreSQL 13+
|
- PostgreSQL 13+ et `pg_dump` (sauvegardes)
|
||||||
- Clé API Visual Crossing (gratuite sur [visualcrossing.com](https://www.visualcrossing.com/))
|
- Clé API Visual Crossing (gratuite sur [visualcrossing.com](https://www.visualcrossing.com/))
|
||||||
|
|
||||||
### Installation
|
### Installation
|
||||||
@@ -61,13 +61,18 @@ npm install
|
|||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# Éditer .env avec vos paramètres de base de données
|
# Éditer .env avec vos paramètres de base de données
|
||||||
|
|
||||||
# Initialiser la base de données
|
# Démarrer le serveur (API, migrations au démarrage) sur le port 3000
|
||||||
npm run db:push
|
npm run dev:server
|
||||||
|
|
||||||
# Démarrer l'application
|
# Dans un autre terminal : l'interface avec rechargement à chaud (Vite),
|
||||||
|
# qui relaie /api vers le serveur
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
|
En production : `npm run build` (interface dans `dist/public`, binaire dans
|
||||||
|
`server-rs/target/release/logiflow`) puis `npm start`, ou l'image Docker
|
||||||
|
(`docker compose up -d`).
|
||||||
|
|
||||||
### Configuration de l'API Météo
|
### Configuration de l'API Météo
|
||||||
1. Créez un compte gratuit sur [Visual Crossing](https://www.visualcrossing.com/)
|
1. Créez un compte gratuit sur [Visual Crossing](https://www.visualcrossing.com/)
|
||||||
2. Obtenez votre clé API
|
2. Obtenez votre clé API
|
||||||
@@ -122,11 +127,9 @@ npm run dev
|
|||||||
- **Wouter** : Routage léger et performant
|
- **Wouter** : Routage léger et performant
|
||||||
|
|
||||||
### Backend
|
### Backend
|
||||||
- **Node.js** : Runtime JavaScript haute performance
|
- **Rust** (axum, tokio, sqlx) : serveur unique, binaire statique (voir `server-rs/README.md`)
|
||||||
- **Express** : Framework web minimaliste
|
- **Schéma** : `shared/schema.ts` (Drizzle) reste la source, d'où est généré `server-rs/src/schema.rs`
|
||||||
- **TypeScript** : Développement sécurisé côté serveur
|
- **Zod** : validation côté interface ; mêmes règles et mêmes messages côté serveur
|
||||||
- **Drizzle ORM** : ORM moderne et type-safe
|
|
||||||
- **Zod** : Validation de schémas robuste
|
|
||||||
|
|
||||||
### Base de Données
|
### Base de Données
|
||||||
- **PostgreSQL** : Base de données relationnelle performante
|
- **PostgreSQL** : Base de données relationnelle performante
|
||||||
@@ -146,13 +149,16 @@ npm run dev
|
|||||||
│ │ ├── pages/ # Pages de l'application
|
│ │ ├── pages/ # Pages de l'application
|
||||||
│ │ ├── lib/ # Utilitaires et configuration
|
│ │ ├── lib/ # Utilitaires et configuration
|
||||||
│ │ └── hooks/ # Hooks React personnalisés
|
│ │ └── hooks/ # Hooks React personnalisés
|
||||||
├── server/ # Backend Express
|
├── server-rs/ # Serveur Rust (API, interface, migrations, tâches de fond)
|
||||||
│ ├── routes.ts # Définition des routes API
|
│ └── src/
|
||||||
│ ├── storage.ts # Interface de stockage
|
│ ├── router.rs # Table des adresses de l'API
|
||||||
│ ├── weatherService.ts # Service météorologique
|
│ ├── modules/ # Une famille de routes par fichier
|
||||||
│ └── index.ts # Point d'entrée du serveur
|
│ ├── startup.rs # Migrations au démarrage
|
||||||
|
│ └── jobs.rs # Sauvegardes automatiques, purge des caches
|
||||||
├── shared/ # Code partagé
|
├── shared/ # Code partagé
|
||||||
│ └── schema.ts # Schémas de base de données
|
│ ├── schema.ts # Schémas de base de données (source de schema.rs)
|
||||||
|
│ └── permissions.json # Matrice des droits (interface et serveur)
|
||||||
|
├── scripts/bench/ # Banc de comparaison avec l'ancien serveur Node
|
||||||
├── migrations/ # Migrations de base de données
|
├── migrations/ # Migrations de base de données
|
||||||
└── backups/ # Dossier des sauvegardes
|
└── backups/ # Dossier des sauvegardes
|
||||||
```
|
```
|
||||||
@@ -168,8 +174,13 @@ DATABASE_URL=postgresql://user:password@localhost:5432/dbname
|
|||||||
VISUAL_CROSSING_API_KEY=votre_clé_api_ici
|
VISUAL_CROSSING_API_KEY=votre_clé_api_ici
|
||||||
|
|
||||||
# Application
|
# Application
|
||||||
NODE_ENV=production
|
|
||||||
PORT=3000
|
PORT=3000
|
||||||
|
SESSION_SECRET=… # signe le cookie de session
|
||||||
|
ENCRYPTION_KEY=… # chiffre les secrets en base (repli : SESSION_SECRET)
|
||||||
|
STATIC_DIR=dist/public # interface construite par Vite
|
||||||
|
BACKUP_DIR=/app/backups
|
||||||
|
ALLOWED_ORIGINS= # autres origines acceptées pour les écritures (facultatif)
|
||||||
|
RUST_LOG=info
|
||||||
```
|
```
|
||||||
|
|
||||||
### Personnalisation
|
### Personnalisation
|
||||||
|
|||||||
+1
-3
@@ -1,5 +1,5 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="fr">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1" />
|
||||||
@@ -15,7 +15,5 @@
|
|||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
<!-- This is a replit script which adds a banner on the top of the page when opened in development mode outside the replit environment -->
|
|
||||||
<script type="text/javascript" src="https://replit.com/public/js/replit-dev-banner.js"></script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -49,7 +49,7 @@ import {
|
|||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
// Type pour les annonces avec relations
|
// Type pour les annonces avec relations
|
||||||
type AnnouncementWithRelations = DashboardMessage & {
|
export type AnnouncementWithRelations = DashboardMessage & {
|
||||||
author: {
|
author: {
|
||||||
id: string | number;
|
id: string | number;
|
||||||
firstName?: string;
|
firstName?: string;
|
||||||
@@ -97,6 +97,45 @@ const getPriorityConfig = (type: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Requête des annonces, partagée avec le tableau de bord (même clé, même URL) :
|
||||||
|
// un seul appel réseau, et les invalidations ['/api/announcements'] des
|
||||||
|
// mutations ci-dessous rafraîchissent les deux affichages.
|
||||||
|
export function getAnnouncementsQueryOptions(selectedStoreId: number | null, role: string | undefined) {
|
||||||
|
return {
|
||||||
|
queryKey: ['/api/announcements', selectedStoreId, role],
|
||||||
|
queryFn: async (): Promise<AnnouncementWithRelations[]> => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
// Pour les admins, permettre le filtrage par magasin sélectionné
|
||||||
|
if (role === 'admin' && selectedStoreId) {
|
||||||
|
params.append('storeId', selectedStoreId.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `/api/announcements${params.toString() ? `?${params.toString()}` : ''}`;
|
||||||
|
const response = await fetch(url, {
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error('❌ [ANNOUNCEMENTS] Fetch failed:', response.status, response.statusText);
|
||||||
|
if (response.status === 401) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
throw new Error(`Failed to fetch announcements: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!Array.isArray(data)) {
|
||||||
|
console.warn('⚠️ [ANNOUNCEMENTS] Data is not an array:', data);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default function AnnouncementCard() {
|
export default function AnnouncementCard() {
|
||||||
const { user } = useAuthUnified();
|
const { user } = useAuthUnified();
|
||||||
const { selectedStoreId } = useStore();
|
const { selectedStoreId } = useStore();
|
||||||
@@ -117,48 +156,9 @@ export default function AnnouncementCard() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Construire l'URL avec les paramètres appropriés
|
|
||||||
const buildApiUrl = () => {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
|
|
||||||
// Pour les admins, permettre le filtrage par magasin sélectionné
|
|
||||||
if (user?.role === 'admin' && selectedStoreId) {
|
|
||||||
params.append('storeId', selectedStoreId.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
return `/api/announcements${params.toString() ? `?${params.toString()}` : ''}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Récupérer les annonces
|
// Récupérer les annonces
|
||||||
const { data: announcements = [], isLoading } = useQuery<AnnouncementWithRelations[]>({
|
const { data: announcements = [], isLoading } = useQuery({
|
||||||
queryKey: ['/api/announcements', selectedStoreId, user?.role],
|
...getAnnouncementsQueryOptions(selectedStoreId, user?.role),
|
||||||
queryFn: async () => {
|
|
||||||
const url = buildApiUrl();
|
|
||||||
console.log('🔍 [ANNOUNCEMENTS] Fetching from:', url);
|
|
||||||
|
|
||||||
const response = await fetch(url, {
|
|
||||||
credentials: 'include'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
console.error('❌ [ANNOUNCEMENTS] Fetch failed:', response.status, response.statusText);
|
|
||||||
if (response.status === 401) {
|
|
||||||
console.log('🔑 [ANNOUNCEMENTS] Authentication required');
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
throw new Error(`Failed to fetch announcements: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
console.log('✅ [ANNOUNCEMENTS] Data received:', data);
|
|
||||||
|
|
||||||
if (!Array.isArray(data)) {
|
|
||||||
console.warn('⚠️ [ANNOUNCEMENTS] Data is not an array:', data);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
enabled: !!user,
|
enabled: !!user,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -171,8 +171,6 @@ export default function AnnouncementCard() {
|
|||||||
|
|
||||||
const method = editingAnnouncement ? 'PUT' : 'POST';
|
const method = editingAnnouncement ? 'PUT' : 'POST';
|
||||||
|
|
||||||
console.log(`🔍 [ANNOUNCEMENTS] ${method} ${url}`, announcementData);
|
|
||||||
|
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method,
|
method,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -241,8 +239,6 @@ export default function AnnouncementCard() {
|
|||||||
|
|
||||||
// Soumission du formulaire
|
// Soumission du formulaire
|
||||||
const onSubmit = (data: InsertAnnouncement) => {
|
const onSubmit = (data: InsertAnnouncement) => {
|
||||||
console.log('🔍 [ANNOUNCEMENTS] Form submitted:', data);
|
|
||||||
|
|
||||||
const announcementData = {
|
const announcementData = {
|
||||||
...data,
|
...data,
|
||||||
createdBy: user?.username || '',
|
createdBy: user?.username || '',
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { format, startOfMonth, endOfMonth, eachDayOfInterval, isSameDay, isSameMonth, isToday } from "date-fns";
|
import { format, startOfMonth, endOfMonth, eachDayOfInterval, isSameMonth, isToday } from "date-fns";
|
||||||
import { fr } from "date-fns/locale";
|
|
||||||
import { safeDate } from "@/lib/dateUtils";
|
import { safeDate } from "@/lib/dateUtils";
|
||||||
import { Plus, Check, MoreHorizontal, Package, Link, MessageSquare } from "lucide-react";
|
import { Plus, Check, MoreHorizontal, Package, Link, MessageSquare } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||||
import { useState } from "react";
|
import { memo, useMemo, useState } from "react";
|
||||||
|
|
||||||
// Composant modal pour afficher les commentaires
|
// Composant modal pour afficher les commentaires
|
||||||
function CommentModal({ isOpen, onClose, comment }: { isOpen: boolean, onClose: () => void, comment: string }) {
|
function CommentModal({ isOpen, onClose, comment }: { isOpen: boolean, onClose: () => void, comment: string }) {
|
||||||
@@ -353,7 +352,10 @@ function DayItemsContainer({ dayOrders, dayDeliveries, onItemClick }: { dayOrder
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CalendarGrid({
|
// Liste vide partagée pour les jours sans élément (référence stable)
|
||||||
|
const NO_ITEMS: any[] = [];
|
||||||
|
|
||||||
|
function CalendarGrid({
|
||||||
currentDate,
|
currentDate,
|
||||||
orders,
|
orders,
|
||||||
deliveries,
|
deliveries,
|
||||||
@@ -363,197 +365,158 @@ export default function CalendarGrid({
|
|||||||
onDateClick,
|
onDateClick,
|
||||||
onItemClick,
|
onItemClick,
|
||||||
}: CalendarGridProps) {
|
}: CalendarGridProps) {
|
||||||
if (import.meta.env.DEV) {
|
// Jours affichés : 6 semaines complètes commençant un lundi
|
||||||
console.log('🗓️ CalendarGrid rendered with:', {
|
const paddedDays = useMemo(() => {
|
||||||
currentDate: currentDate?.toISOString(),
|
const monthStart = startOfMonth(currentDate);
|
||||||
ordersCount: orders?.length || 0,
|
const monthEnd = endOfMonth(currentDate);
|
||||||
deliveriesCount: deliveries?.length || 0,
|
|
||||||
publicitiesCount: publicities?.length || 0,
|
|
||||||
selectedStoreId,
|
|
||||||
userGroupsCount: userGroups?.length || 0
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const monthStart = startOfMonth(currentDate);
|
|
||||||
const monthEnd = endOfMonth(currentDate);
|
|
||||||
|
|
||||||
// Helper function to filter publicities based on user's assigned stores
|
// Get all days in the month
|
||||||
const getPublicitiesForDate = (date: Date) => {
|
const monthDays = eachDayOfInterval({
|
||||||
const dateStr = format(date, 'yyyy-MM-dd');
|
start: monthStart,
|
||||||
|
end: monthEnd,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pad the calendar to start on Monday
|
||||||
|
const firstDayOfWeek = monthStart.getDay();
|
||||||
|
const startPadding = firstDayOfWeek === 0 ? 6 : firstDayOfWeek - 1;
|
||||||
|
const days: Date[] = [];
|
||||||
|
|
||||||
|
// Add padding days from previous month
|
||||||
|
for (let i = startPadding; i > 0; i--) {
|
||||||
|
const paddingDate = new Date(monthStart);
|
||||||
|
paddingDate.setDate(paddingDate.getDate() - i);
|
||||||
|
days.push(paddingDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add current month days
|
||||||
|
days.push(...monthDays);
|
||||||
|
|
||||||
|
// Add padding days from next month to complete the grid
|
||||||
|
const remainingCells = 42 - days.length; // 6 weeks * 7 days
|
||||||
|
for (let i = 1; i <= remainingCells; i++) {
|
||||||
|
const paddingDate = new Date(monthEnd);
|
||||||
|
paddingDate.setDate(paddingDate.getDate() + i);
|
||||||
|
days.push(paddingDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
return days;
|
||||||
|
}, [currentDate]);
|
||||||
|
|
||||||
|
// Commandes et livraisons indexées par jour ('yyyy-MM-dd', heure locale comme
|
||||||
|
// isSameDay) en une seule passe sur chaque liste, au lieu de filtrer toutes
|
||||||
|
// les listes pour chacune des 42 cases à chaque rendu
|
||||||
|
const itemsByDay = useMemo(() => {
|
||||||
|
const index = new Map<string, { orders: OrderWithRelations[]; deliveries: DeliveryWithRelations[] }>();
|
||||||
|
const getDayEntry = (key: string) => {
|
||||||
|
let entry = index.get(key);
|
||||||
|
if (!entry) {
|
||||||
|
entry = { orders: [], deliveries: [] };
|
||||||
|
index.set(key, entry);
|
||||||
|
}
|
||||||
|
return entry;
|
||||||
|
};
|
||||||
|
let invalidCount = 0;
|
||||||
|
|
||||||
|
for (const order of orders) {
|
||||||
|
// Protection contre undefined/null
|
||||||
|
if (!order || !order.supplier) {
|
||||||
|
invalidCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Essayer plusieurs champs de date possibles
|
||||||
|
const orderDate = safeDate(order.plannedDate || order.createdAt);
|
||||||
|
if (orderDate) {
|
||||||
|
getDayEntry(format(orderDate, 'yyyy-MM-dd')).orders.push(order);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const delivery of deliveries) {
|
||||||
|
// Protection contre undefined/null
|
||||||
|
if (!delivery || !delivery.supplier) {
|
||||||
|
invalidCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Essayer plusieurs champs de date possibles
|
||||||
|
const deliveryDate = safeDate(delivery.scheduledDate || delivery.deliveredDate || delivery.createdAt);
|
||||||
|
if (deliveryDate) {
|
||||||
|
getDayEntry(format(deliveryDate, 'yyyy-MM-dd')).deliveries.push(delivery);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invalidCount > 0) {
|
||||||
|
console.warn(`⚠️ CalendarGrid: ${invalidCount} élément(s) sans fournisseur ignoré(s)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return index;
|
||||||
|
}, [orders, deliveries]);
|
||||||
|
|
||||||
|
// Publicités par jour affiché : filtrage par magasin fait une seule fois,
|
||||||
|
// puis répartition sur les 42 jours selon la période de chaque publicité
|
||||||
|
const publicitiesByDay = useMemo(() => {
|
||||||
|
const byDay = new Map<string, any[]>();
|
||||||
|
|
||||||
// Protection contre les données invalides qui causent des plantages en production
|
// Protection contre les données invalides qui causent des plantages en production
|
||||||
if (!publicities || !Array.isArray(publicities)) {
|
if (!publicities || !Array.isArray(publicities)) {
|
||||||
console.warn('⚠️ Invalid publicities data:', publicities);
|
console.warn('⚠️ Invalid publicities data:', publicities);
|
||||||
return [];
|
return byDay;
|
||||||
}
|
|
||||||
|
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
console.log('🔍 CalendarGrid getPublicitiesForDate:', {
|
|
||||||
date: dateStr,
|
|
||||||
totalPublicities: publicities.length,
|
|
||||||
selectedStoreId,
|
|
||||||
userGroups: userGroups?.length || 0
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return publicities.filter(pub => {
|
let invalidCount = 0;
|
||||||
|
const storePublicities = publicities.filter(pub => {
|
||||||
// Vérifications de sécurité pour éviter les plantages
|
// Vérifications de sécurité pour éviter les plantages
|
||||||
if (!pub || typeof pub !== 'object') {
|
if (!pub || typeof pub !== 'object' || !pub.startDate || !pub.endDate) {
|
||||||
console.warn('⚠️ Invalid publicity object:', pub);
|
invalidCount++;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// Check if the date is within the publicity period
|
|
||||||
const pubStart = pub.startDate;
|
|
||||||
const pubEnd = pub.endDate;
|
|
||||||
|
|
||||||
if (!pubStart || !pubEnd) {
|
// If no store is selected and user has no assigned groups, show only publicities with participations
|
||||||
console.warn('⚠️ Publicity missing dates:', { pubNumber: pub.pubNumber, startDate: pubStart, endDate: pubEnd });
|
if (!selectedStoreId && (!userGroups || userGroups.length === 0)) {
|
||||||
return false;
|
return pub.participations && Array.isArray(pub.participations) && pub.participations.length > 0;
|
||||||
}
|
|
||||||
|
|
||||||
if (dateStr < pubStart || dateStr > pubEnd) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no store is selected and user has no assigned groups, show only publicities with participations
|
|
||||||
if (!selectedStoreId && (!userGroups || userGroups.length === 0)) {
|
|
||||||
const hasParticipations = pub.participations && Array.isArray(pub.participations) && pub.participations.length > 0;
|
|
||||||
if (hasParticipations && import.meta.env.DEV) {
|
|
||||||
console.log('📋 Publicity has participations (no store selected):', { pubNumber: pub.pubNumber, participationCount: pub.participations.length });
|
|
||||||
}
|
}
|
||||||
return hasParticipations;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If a specific store is selected, check if that store participates
|
// If a specific store is selected, check if that store participates
|
||||||
if (selectedStoreId) {
|
if (selectedStoreId) {
|
||||||
const matches = pub.participations && Array.isArray(pub.participations) &&
|
return pub.participations && Array.isArray(pub.participations) &&
|
||||||
pub.participations.some((pg: any) => pg?.groupId === selectedStoreId);
|
pub.participations.some((pg: any) => pg?.groupId === selectedStoreId);
|
||||||
if (matches && import.meta.env.DEV) {
|
|
||||||
console.log('🎯 Publicity matches selected store:', { pubNumber: pub.pubNumber, selectedStoreId });
|
|
||||||
}
|
}
|
||||||
return matches;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no specific store selected but user has assigned stores,
|
// If no specific store selected but user has assigned stores,
|
||||||
// show publicities where any of user's stores participate
|
// show publicities where any of user's stores participate
|
||||||
if (userGroups && Array.isArray(userGroups) && userGroups.length > 0) {
|
if (userGroups && Array.isArray(userGroups) && userGroups.length > 0) {
|
||||||
const userGroupIds = userGroups.map((ug: any) => ug?.groupId).filter(id => id !== undefined);
|
const userGroupIds = userGroups.map((ug: any) => ug?.groupId).filter(id => id !== undefined);
|
||||||
const matches = pub.participations && Array.isArray(pub.participations) &&
|
return pub.participations && Array.isArray(pub.participations) &&
|
||||||
pub.participations.some((pg: any) => pg?.groupId && userGroupIds.includes(pg.groupId));
|
pub.participations.some((pg: any) => pg?.groupId && userGroupIds.includes(pg.groupId));
|
||||||
if (matches) {
|
|
||||||
console.log('👥 Publicity matches user groups:', { pubNumber: pub.pubNumber, userGroupIds });
|
|
||||||
}
|
}
|
||||||
return matches;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default case: only show publicities with participations
|
// Default case: only show publicities with participations
|
||||||
const hasParticipations = pub.participations && Array.isArray(pub.participations) && pub.participations.length > 0;
|
return pub.participations && Array.isArray(pub.participations) && pub.participations.length > 0;
|
||||||
if (hasParticipations) {
|
|
||||||
console.log('📋 Publicity has participations (default case):', { pubNumber: pub.pubNumber, participationCount: pub.participations.length });
|
|
||||||
}
|
|
||||||
return hasParticipations;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (invalidCount > 0) {
|
||||||
|
console.warn(`⚠️ CalendarGrid: ${invalidCount} publicité(s) invalide(s) ou sans dates ignorée(s)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (storePublicities.length > 0) {
|
||||||
|
for (const date of paddedDays) {
|
||||||
|
const dateStr = format(date, 'yyyy-MM-dd');
|
||||||
|
// Check if the date is within the publicity period
|
||||||
|
const dayPublicities = storePublicities.filter(pub => !(dateStr < pub.startDate || dateStr > pub.endDate));
|
||||||
|
if (dayPublicities.length > 0) {
|
||||||
|
byDay.set(dateStr, dayPublicities);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Error filtering publicities:', error);
|
console.error('❌ Error filtering publicities:', error);
|
||||||
return [];
|
byDay.clear();
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
// Get all days in the month
|
return byDay;
|
||||||
const monthDays = eachDayOfInterval({
|
}, [publicities, selectedStoreId, userGroups, paddedDays]);
|
||||||
start: monthStart,
|
|
||||||
end: monthEnd,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Pad the calendar to start on Monday
|
|
||||||
const firstDayOfWeek = monthStart.getDay();
|
|
||||||
const startPadding = firstDayOfWeek === 0 ? 6 : firstDayOfWeek - 1;
|
|
||||||
const paddedDays = [];
|
|
||||||
|
|
||||||
// Add padding days from previous month
|
|
||||||
for (let i = startPadding; i > 0; i--) {
|
|
||||||
const paddingDate = new Date(monthStart);
|
|
||||||
paddingDate.setDate(paddingDate.getDate() - i);
|
|
||||||
paddedDays.push(paddingDate);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add current month days
|
|
||||||
paddedDays.push(...monthDays);
|
|
||||||
|
|
||||||
// Add padding days from next month to complete the grid
|
|
||||||
const remainingCells = 42 - paddedDays.length; // 6 weeks * 7 days
|
|
||||||
for (let i = 1; i <= remainingCells; i++) {
|
|
||||||
const paddingDate = new Date(monthEnd);
|
|
||||||
paddingDate.setDate(paddingDate.getDate() + i);
|
|
||||||
paddedDays.push(paddingDate);
|
|
||||||
}
|
|
||||||
|
|
||||||
const weekDays = ["Lun", "Mar", "Mer", "Jeu", "Ven", "Sam", "Dim"];
|
const weekDays = ["Lun", "Mar", "Mer", "Jeu", "Ven", "Sam", "Dim"];
|
||||||
|
|
||||||
const getItemsForDate = (date: Date) => {
|
|
||||||
// Debug: Log des commandes reçues (seulement une fois)
|
|
||||||
if (orders.length > 0 && date.getDate() === 1) {
|
|
||||||
console.log('📅 CalendarGrid Debug - Orders received:', orders.length);
|
|
||||||
console.log('📅 First order structure:', orders[0]);
|
|
||||||
console.log('📅 All orders dates:', orders.map(o => ({ id: o.id, plannedDate: o.plannedDate, supplier: o.supplier?.name })));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Debug: Log des livraisons reçues (seulement une fois)
|
|
||||||
if (deliveries.length > 0 && date.getDate() === 1) {
|
|
||||||
console.log('🚛 CalendarGrid Debug - Deliveries received:', deliveries.length);
|
|
||||||
console.log('🚛 First delivery structure:', deliveries[0]);
|
|
||||||
console.log('🚛 All deliveries dates:', deliveries.map(d => ({ id: d.id, scheduledDate: d.scheduledDate, supplier: d.supplier?.name })));
|
|
||||||
}
|
|
||||||
|
|
||||||
const dayOrders = orders.filter(order => {
|
|
||||||
// Protection contre undefined/null
|
|
||||||
if (!order || !order.supplier) {
|
|
||||||
console.warn('📅 Invalid order found:', order);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Essayer plusieurs champs de date possibles
|
|
||||||
const orderDate = safeDate(order.plannedDate || order.createdAt);
|
|
||||||
const matches = orderDate && isSameDay(orderDate, date);
|
|
||||||
|
|
||||||
if (matches) {
|
|
||||||
console.log('📅 Order matches date:', {
|
|
||||||
orderId: order.id,
|
|
||||||
supplier: order.supplier?.name,
|
|
||||||
plannedDate: order.plannedDate,
|
|
||||||
matchingDate: format(date, 'yyyy-MM-dd')
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return matches;
|
|
||||||
});
|
|
||||||
|
|
||||||
const dayDeliveries = deliveries.filter(delivery => {
|
|
||||||
// Protection contre undefined/null
|
|
||||||
if (!delivery || !delivery.supplier) {
|
|
||||||
console.warn('🚛 Invalid delivery found:', delivery);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Essayer plusieurs champs de date possibles
|
|
||||||
const deliveryDate = safeDate(delivery.scheduledDate || delivery.deliveredDate || delivery.createdAt);
|
|
||||||
const matches = deliveryDate && isSameDay(deliveryDate, date);
|
|
||||||
|
|
||||||
if (matches) {
|
|
||||||
console.log('🚛 Delivery matches date:', {
|
|
||||||
deliveryId: delivery.id,
|
|
||||||
supplier: delivery.supplier?.name,
|
|
||||||
scheduledDate: delivery.scheduledDate,
|
|
||||||
matchingDate: format(date, 'yyyy-MM-dd')
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return matches;
|
|
||||||
});
|
|
||||||
|
|
||||||
return { orders: dayOrders, deliveries: dayDeliveries };
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white shadow-xl border-2 border-gray-300 overflow-hidden">
|
<div className="bg-white shadow-xl border-2 border-gray-300 overflow-hidden">
|
||||||
{/* Calendar Header - Design moderne épuré */}
|
{/* Calendar Header - Design moderne épuré */}
|
||||||
@@ -576,8 +539,11 @@ export default function CalendarGrid({
|
|||||||
const isCurrentMonth = isSameMonth(date, currentDate);
|
const isCurrentMonth = isSameMonth(date, currentDate);
|
||||||
const isTodayDate = isToday(date);
|
const isTodayDate = isToday(date);
|
||||||
const isWeekend = date.getDay() === 0 || date.getDay() === 6;
|
const isWeekend = date.getDay() === 0 || date.getDay() === 6;
|
||||||
const { orders: dayOrders, deliveries: dayDeliveries } = getItemsForDate(date);
|
const dayKey = format(date, 'yyyy-MM-dd');
|
||||||
const dayPublicities = getPublicitiesForDate(date);
|
const dayItems = itemsByDay.get(dayKey);
|
||||||
|
const dayOrders = dayItems ? dayItems.orders : NO_ITEMS;
|
||||||
|
const dayDeliveries = dayItems ? dayItems.deliveries : NO_ITEMS;
|
||||||
|
const dayPublicities = publicitiesByDay.get(dayKey) || NO_ITEMS;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -694,3 +660,6 @@ export default function CalendarGrid({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mémorisé : l'ouverture d'une modale dans la page Calendrier ne recalcule pas la grille
|
||||||
|
export default memo(CalendarGrid);
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useLocation } from "wouter";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -39,13 +40,9 @@ interface DlcAlertModalProps {
|
|||||||
export function DlcAlertModal({ isOpen, onClose, dlcStats, selectedStoreId }: DlcAlertModalProps) {
|
export function DlcAlertModal({ isOpen, onClose, dlcStats, selectedStoreId }: DlcAlertModalProps) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const [, setLocation] = useLocation();
|
||||||
const [snoozeUntil, setSnoozeUntil] = useState<Date | null>(null);
|
const [snoozeUntil, setSnoozeUntil] = useState<Date | null>(null);
|
||||||
|
|
||||||
// Invalider les queries DLC quand le magasin change
|
|
||||||
useEffect(() => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products"] });
|
|
||||||
}, [selectedStoreId, queryClient]);
|
|
||||||
|
|
||||||
// Fetch detailed DLC products for the modal
|
// Fetch detailed DLC products for the modal
|
||||||
const { data: expiredProducts = [] } = useQuery<DlcProduct[]>({
|
const { data: expiredProducts = [] } = useQuery<DlcProduct[]>({
|
||||||
queryKey: ["/api/dlc-products", "expires", selectedStoreId],
|
queryKey: ["/api/dlc-products", "expires", selectedStoreId],
|
||||||
@@ -138,8 +135,10 @@ export function DlcAlertModal({ isOpen, onClose, dlcStats, selectedStoreId }: Dl
|
|||||||
markStockEpuiseMutation.mutate(productId);
|
markStockEpuiseMutation.mutate(productId);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Navigation interne : pas de rechargement complet de l'application
|
||||||
const handleViewDlcModule = () => {
|
const handleViewDlcModule = () => {
|
||||||
window.location.href = '/dlc';
|
setLocation('/dlc');
|
||||||
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ReactNode, useState, useEffect } from "react";
|
import { ReactNode, Suspense, useState, useEffect, useMemo, useRef } from "react";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useLocation } from "wouter";
|
import { useLocation } from "wouter";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
@@ -9,8 +9,10 @@ import { useIsMobile } from "@/hooks/use-mobile";
|
|||||||
import { useScreenSize } from "@/hooks/use-screen-size";
|
import { useScreenSize } from "@/hooks/use-screen-size";
|
||||||
import { StoreProvider } from "@/contexts/StoreContext";
|
import { StoreProvider } from "@/contexts/StoreContext";
|
||||||
import Sidebar from "./Sidebar";
|
import Sidebar from "./Sidebar";
|
||||||
|
import ErrorBoundary from "./ErrorBoundary";
|
||||||
import WeatherWidget from "./WeatherWidget";
|
import WeatherWidget from "./WeatherWidget";
|
||||||
import DateWidget from "./DateWidget";
|
import DateWidget from "./DateWidget";
|
||||||
|
import { getNavItemForPath } from "@/lib/navigation";
|
||||||
import type { Group } from "@shared/schema";
|
import type { Group } from "@shared/schema";
|
||||||
|
|
||||||
|
|
||||||
@@ -18,6 +20,23 @@ interface LayoutProps {
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tableau vide stable : évite de recréer la valeur du contexte magasin à chaque
|
||||||
|
// rendu tant que /api/groups n'a pas répondu
|
||||||
|
const EMPTY_STORES: Group[] = [];
|
||||||
|
|
||||||
|
// Affiché dans la zone de contenu pendant le chargement du code d'une page
|
||||||
|
// (le menu et l'en-tête restent visibles)
|
||||||
|
function PageFallback() {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-16">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||||
|
<p className="text-gray-600">Chargement...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function Layout({ children }: LayoutProps) {
|
export default function Layout({ children }: LayoutProps) {
|
||||||
const { user } = useAuthUnified();
|
const { user } = useAuthUnified();
|
||||||
const [location] = useLocation();
|
const [location] = useLocation();
|
||||||
@@ -45,11 +64,41 @@ export default function Layout({ children }: LayoutProps) {
|
|||||||
|
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
|
|
||||||
const { data: stores = [] } = useQuery<Group[]>({
|
// Titre de l'onglet du navigateur tiré du menu : « Livraisons — LogiFlow »
|
||||||
|
useEffect(() => {
|
||||||
|
const navItem = getNavItemForPath(location);
|
||||||
|
document.title = navItem ? `${navItem.label} — LogiFlow` : "LogiFlow";
|
||||||
|
}, [location]);
|
||||||
|
|
||||||
|
const { data: stores = EMPTY_STORES } = useQuery<Group[]>({
|
||||||
queryKey: ['/api/groups'],
|
queryKey: ['/api/groups'],
|
||||||
enabled: !!user,
|
enabled: !!user,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Changement de magasin demandé dans le sélecteur : les données dépendantes du
|
||||||
|
// magasin sont invalidées après le rendu (effet ci-dessous) et non dans le
|
||||||
|
// gestionnaire. À ce moment les pages ont déjà basculé sur les clés du nouveau
|
||||||
|
// magasin : les requêtes de l'ancien magasin ne sont plus actives et ne sont
|
||||||
|
// donc plus rechargées pour rien, et cancelRefetch: false réutilise les
|
||||||
|
// requêtes du nouveau magasin déjà en cours. L'invalidation est conservée pour
|
||||||
|
// les requêtes dont la clé ne contient pas le magasin (ex. ['/api/orders'] des
|
||||||
|
// modales de livraison).
|
||||||
|
const storeChangePendingRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!storeChangePendingRef.current) return;
|
||||||
|
storeChangePendingRef.current = false;
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
predicate: (query) => {
|
||||||
|
const key = query.queryKey;
|
||||||
|
return Boolean(key[0]?.toString().includes('/api/orders') ||
|
||||||
|
key[0]?.toString().includes('/api/deliveries') ||
|
||||||
|
key[0]?.toString().includes('/api/stats/monthly') ||
|
||||||
|
key[0]?.toString().includes('/api/tasks'));
|
||||||
|
}
|
||||||
|
}, { cancelRefetch: false });
|
||||||
|
}, [selectedStoreId, queryClient]);
|
||||||
|
|
||||||
// Effet pour marquer l'initialisation comme terminée
|
// Effet pour marquer l'initialisation comme terminée
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user && stores.length > 0) {
|
if (user && stores.length > 0) {
|
||||||
@@ -82,8 +131,21 @@ export default function Layout({ children }: LayoutProps) {
|
|||||||
window.location.href = "/api/logout";
|
window.location.href = "/api/logout";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Valeur du contexte mémorisée : les consommateurs de useStore() ne sont
|
||||||
|
// re-rendus que lorsqu'une de ces valeurs change réellement
|
||||||
|
const storeContextValue = useMemo(() => ({
|
||||||
|
selectedStoreId,
|
||||||
|
setSelectedStoreId,
|
||||||
|
stores,
|
||||||
|
sidebarCollapsed,
|
||||||
|
setSidebarCollapsed,
|
||||||
|
mobileMenuOpen,
|
||||||
|
setMobileMenuOpen,
|
||||||
|
storeInitialized
|
||||||
|
}), [selectedStoreId, stores, sidebarCollapsed, mobileMenuOpen, storeInitialized]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StoreProvider value={{ selectedStoreId, setSelectedStoreId, stores, sidebarCollapsed, setSidebarCollapsed, mobileMenuOpen, setMobileMenuOpen, storeInitialized }}>
|
<StoreProvider value={storeContextValue}>
|
||||||
<div className="layout-container flex bg-gray-50">
|
<div className="layout-container flex bg-gray-50">
|
||||||
{/* Mobile overlay for normal mode */}
|
{/* Mobile overlay for normal mode */}
|
||||||
{isMobile && mobileMenuOpen && (
|
{isMobile && mobileMenuOpen && (
|
||||||
@@ -135,15 +197,10 @@ export default function Layout({ children }: LayoutProps) {
|
|||||||
value={selectedStoreId?.toString() || (user.role === 'admin' ? "all" : "")}
|
value={selectedStoreId?.toString() || (user.role === 'admin' ? "all" : "")}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
const newStoreId = value === "all" ? null : parseInt(value);
|
const newStoreId = value === "all" ? null : parseInt(value);
|
||||||
queryClient.invalidateQueries({
|
// Invalidation des données du magasin après le rendu (voir plus haut)
|
||||||
predicate: (query) => {
|
if (newStoreId !== selectedStoreId) {
|
||||||
const key = query.queryKey;
|
storeChangePendingRef.current = true;
|
||||||
return Boolean(key[0]?.toString().includes('/api/orders') ||
|
}
|
||||||
key[0]?.toString().includes('/api/deliveries') ||
|
|
||||||
key[0]?.toString().includes('/api/stats/monthly') ||
|
|
||||||
key[0]?.toString().includes('/api/tasks'));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sauvegarder dans localStorage et mettre à jour l'état
|
// Sauvegarder dans localStorage et mettre à jour l'état
|
||||||
if (newStoreId) {
|
if (newStoreId) {
|
||||||
@@ -210,7 +267,13 @@ export default function Layout({ children }: LayoutProps) {
|
|||||||
|
|
||||||
<div className={`flex-1 bg-gray-50 h-full overflow-y-auto overflow-x-hidden ${isMobileOrTablet ? 'p-3' : 'p-6'
|
<div className={`flex-1 bg-gray-50 h-full overflow-y-auto overflow-x-hidden ${isMobileOrTablet ? 'p-3' : 'p-6'
|
||||||
}`} style={{ maxWidth: '100%' }}>
|
}`} style={{ maxWidth: '100%' }}>
|
||||||
{children}
|
{/* Une erreur dans une page (ou l'échec du chargement de son fichier JS)
|
||||||
|
n'efface plus le menu ; réinitialisée à chaque changement d'URL */}
|
||||||
|
<ErrorBoundary key={location}>
|
||||||
|
<Suspense fallback={<PageFallback />}>
|
||||||
|
{children}
|
||||||
|
</Suspense>
|
||||||
|
</ErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
@@ -1,148 +0,0 @@
|
|||||||
import { Home, Package, Truck, ClipboardList, MoreHorizontal } from "lucide-react"
|
|
||||||
import { Link, useLocation } from "wouter"
|
|
||||||
import { useState } from "react"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import {
|
|
||||||
Sheet,
|
|
||||||
SheetContent,
|
|
||||||
SheetDescription,
|
|
||||||
SheetHeader,
|
|
||||||
SheetTitle,
|
|
||||||
SheetTrigger,
|
|
||||||
} from "@/components/ui/sheet"
|
|
||||||
import type { User } from "@shared/schema"
|
|
||||||
|
|
||||||
interface NavItem {
|
|
||||||
path: string
|
|
||||||
label: string
|
|
||||||
icon: any
|
|
||||||
primary?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MoreMenuItem {
|
|
||||||
path: string
|
|
||||||
label: string
|
|
||||||
icon: any
|
|
||||||
roles?: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const primaryNavItems: NavItem[] = [
|
|
||||||
{ path: "/", label: "Accueil", icon: Home, primary: true },
|
|
||||||
{ path: "/orders", label: "Commandes", icon: Package, primary: true },
|
|
||||||
{ path: "/deliveries", label: "Livraisons", icon: Truck, primary: true },
|
|
||||||
{ path: "/tasks", label: "Tâches", icon: ClipboardList, primary: true },
|
|
||||||
]
|
|
||||||
|
|
||||||
const moreMenuItems: MoreMenuItem[] = [
|
|
||||||
{ path: "/bl-reconciliation", label: "Rapprochement", icon: ClipboardList, roles: ["admin", "directeur", "manager"] },
|
|
||||||
{ path: "/publicities", label: "Publicités", icon: Package, roles: ["admin", "directeur", "manager"] },
|
|
||||||
{ path: "/customer-orders", label: "Commandes Clients", icon: Package, roles: ["admin", "directeur", "manager"] },
|
|
||||||
{ path: "/avoirs", label: "Avoirs", icon: Package, roles: ["admin", "directeur", "manager"] },
|
|
||||||
{ path: "/dlc", label: "DLC", icon: Package, roles: ["admin", "directeur", "manager"] },
|
|
||||||
{ path: "/sav", label: "SAV", icon: ClipboardList, roles: ["admin", "directeur", "manager"] },
|
|
||||||
{ path: "/calendar", label: "Calendrier", icon: ClipboardList },
|
|
||||||
]
|
|
||||||
|
|
||||||
interface PhoneBottomNavProps {
|
|
||||||
user: User
|
|
||||||
location: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PhoneBottomNav({ user, location }: PhoneBottomNavProps) {
|
|
||||||
const [moreSheetOpen, setMoreSheetOpen] = useState(false)
|
|
||||||
|
|
||||||
const isActive = (path: string) => {
|
|
||||||
if (path === "/") return location === "/"
|
|
||||||
return location.startsWith(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasPermission = (roles?: string[]) => {
|
|
||||||
if (!roles || !user) return true
|
|
||||||
return roles.includes(user.role)
|
|
||||||
}
|
|
||||||
|
|
||||||
const filteredMoreItems = moreMenuItems.filter(item => hasPermission(item.roles))
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 z-50 safe-area-pb bottom-nav">
|
|
||||||
<div className="flex items-center justify-around py-2 px-1">
|
|
||||||
{/* Navigation principale */}
|
|
||||||
{primaryNavItems.map((item) => {
|
|
||||||
const Icon = item.icon
|
|
||||||
const active = isActive(item.path)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link key={item.path} href={item.path}>
|
|
||||||
<div
|
|
||||||
className={`flex flex-col items-center justify-center min-w-[60px] py-2 px-1 rounded-lg transition-all duration-200 ${
|
|
||||||
active
|
|
||||||
? 'bg-blue-50 text-blue-600'
|
|
||||||
: 'text-gray-600 hover:bg-gray-50'
|
|
||||||
}`}
|
|
||||||
data-testid={`nav-${item.path.replace('/', 'home')}`}
|
|
||||||
>
|
|
||||||
<Icon className={`w-5 h-5 ${active ? 'text-blue-600' : 'text-gray-600'}`} />
|
|
||||||
<span className={`text-xs mt-1 font-medium ${
|
|
||||||
active ? 'text-blue-600' : 'text-gray-600'
|
|
||||||
}`}>
|
|
||||||
{item.label}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Menu "Plus" */}
|
|
||||||
<Sheet open={moreSheetOpen} onOpenChange={setMoreSheetOpen}>
|
|
||||||
<SheetTrigger asChild>
|
|
||||||
<div
|
|
||||||
className="flex flex-col items-center justify-center min-w-[60px] py-2 px-1 rounded-lg transition-all duration-200 text-gray-600 hover:bg-gray-50 cursor-pointer"
|
|
||||||
data-testid="nav-more"
|
|
||||||
>
|
|
||||||
<MoreHorizontal className="w-5 h-5 text-gray-600" />
|
|
||||||
<span className="text-xs mt-1 font-medium text-gray-600">Plus</span>
|
|
||||||
</div>
|
|
||||||
</SheetTrigger>
|
|
||||||
<SheetContent side="bottom" className="h-auto max-h-[60vh] rounded-t-lg">
|
|
||||||
<SheetHeader>
|
|
||||||
<SheetTitle>Navigation</SheetTitle>
|
|
||||||
<SheetDescription>
|
|
||||||
Accédez aux autres sections de l'application
|
|
||||||
</SheetDescription>
|
|
||||||
</SheetHeader>
|
|
||||||
<div className="grid grid-cols-3 gap-4 mt-6 pb-4">
|
|
||||||
{filteredMoreItems.map((item) => {
|
|
||||||
const Icon = item.icon
|
|
||||||
const active = isActive(item.path)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={item.path}
|
|
||||||
href={item.path}
|
|
||||||
onClick={() => setMoreSheetOpen(false)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`flex flex-col items-center justify-center p-4 rounded-lg transition-all duration-200 min-h-[80px] ${
|
|
||||||
active
|
|
||||||
? 'bg-blue-50 text-blue-600 border-2 border-blue-200'
|
|
||||||
: 'bg-gray-50 text-gray-700 hover:bg-gray-100 border-2 border-transparent'
|
|
||||||
}`}
|
|
||||||
data-testid={`more-nav-${item.path.replace('/', '')}`}
|
|
||||||
>
|
|
||||||
<Icon className={`w-6 h-6 ${active ? 'text-blue-600' : 'text-gray-600'}`} />
|
|
||||||
<span className={`text-sm mt-2 font-medium text-center ${
|
|
||||||
active ? 'text-blue-600' : 'text-gray-700'
|
|
||||||
}`}>
|
|
||||||
{item.label}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -40,13 +40,36 @@ export default function ReconciliationComments({ deliveryId, className = "" }: R
|
|||||||
const [newComment, setNewComment] = useState("");
|
const [newComment, setNewComment] = useState("");
|
||||||
const [editComment, setEditComment] = useState("");
|
const [editComment, setEditComment] = useState("");
|
||||||
|
|
||||||
|
const commentsQueryKey = [`/api/deliveries/${deliveryId}/reconciliation-comments`];
|
||||||
|
|
||||||
// Récupérer les commentaires
|
// Récupérer les commentaires
|
||||||
const { data: comments = [], isLoading } = useQuery({
|
const { data: comments = [], isLoading } = useQuery({
|
||||||
queryKey: [`/api/deliveries/${deliveryId}/reconciliation-comments`],
|
queryKey: commentsQueryKey,
|
||||||
queryFn: () => apiRequest(`/api/deliveries/${deliveryId}/reconciliation-comments`),
|
queryFn: () => apiRequest(`/api/deliveries/${deliveryId}/reconciliation-comments`),
|
||||||
enabled: !!deliveryId,
|
enabled: !!deliveryId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Compteur de commentaires affiché dans le rapprochement : mis à jour
|
||||||
|
// localement dans la liste en cache (même clé que BLReconciliation) plutôt
|
||||||
|
// que de retélécharger toute la liste des livraisons pour un compteur.
|
||||||
|
// Les listes qui ne contiennent pas la livraison ne sont pas touchées
|
||||||
|
// (undefined : aucune écriture, leur état « périmé » éventuel est conservé).
|
||||||
|
const adjustCommentsCount = (delta: number) => {
|
||||||
|
queryClient.setQueriesData({ queryKey: ['/api/deliveries', 'reconciliation'] }, (old: unknown) => {
|
||||||
|
if (!Array.isArray(old) || !old.some((delivery: any) => delivery?.id === deliveryId)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return old.map((delivery: any) =>
|
||||||
|
delivery?.id === deliveryId
|
||||||
|
? {
|
||||||
|
...delivery,
|
||||||
|
reconciliationCommentsCount: Math.max(0, (Number(delivery.reconciliationCommentsCount) || 0) + delta),
|
||||||
|
}
|
||||||
|
: delivery
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// Créer un commentaire
|
// Créer un commentaire
|
||||||
const createCommentMutation = useMutation({
|
const createCommentMutation = useMutation({
|
||||||
mutationFn: async (content: string) => {
|
mutationFn: async (content: string) => {
|
||||||
@@ -57,10 +80,8 @@ export default function ReconciliationComments({ deliveryId, className = "" }: R
|
|||||||
title: "Succès",
|
title: "Succès",
|
||||||
description: "Commentaire ajouté avec succès",
|
description: "Commentaire ajouté avec succès",
|
||||||
});
|
});
|
||||||
queryClient.invalidateQueries({ queryKey: [`/api/deliveries/${deliveryId}/reconciliation-comments`] });
|
queryClient.invalidateQueries({ queryKey: commentsQueryKey });
|
||||||
// Invalider aussi les queries des livraisons pour mettre à jour le compteur de commentaires
|
adjustCommentsCount(1);
|
||||||
queryClient.invalidateQueries({ queryKey: ['/api/deliveries'] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['/api/deliveries/bl'] });
|
|
||||||
setIsAdding(false);
|
setIsAdding(false);
|
||||||
setNewComment("");
|
setNewComment("");
|
||||||
},
|
},
|
||||||
@@ -83,10 +104,8 @@ export default function ReconciliationComments({ deliveryId, className = "" }: R
|
|||||||
title: "Succès",
|
title: "Succès",
|
||||||
description: "Commentaire modifié avec succès",
|
description: "Commentaire modifié avec succès",
|
||||||
});
|
});
|
||||||
queryClient.invalidateQueries({ queryKey: [`/api/deliveries/${deliveryId}/reconciliation-comments`] });
|
// Le nombre de commentaires ne change pas : la liste des livraisons reste valable
|
||||||
// Invalider aussi les queries des livraisons pour mettre à jour le compteur de commentaires
|
queryClient.invalidateQueries({ queryKey: commentsQueryKey });
|
||||||
queryClient.invalidateQueries({ queryKey: ['/api/deliveries'] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['/api/deliveries/bl'] });
|
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
@@ -108,10 +127,8 @@ export default function ReconciliationComments({ deliveryId, className = "" }: R
|
|||||||
title: "Succès",
|
title: "Succès",
|
||||||
description: "Commentaire supprimé avec succès",
|
description: "Commentaire supprimé avec succès",
|
||||||
});
|
});
|
||||||
queryClient.invalidateQueries({ queryKey: [`/api/deliveries/${deliveryId}/reconciliation-comments`] });
|
queryClient.invalidateQueries({ queryKey: commentsQueryKey });
|
||||||
// Invalider aussi les queries des livraisons pour mettre à jour le compteur de commentaires
|
adjustCommentsCount(-1);
|
||||||
queryClient.invalidateQueries({ queryKey: ['/api/deliveries'] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['/api/deliveries/bl'] });
|
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
toast({
|
toast({
|
||||||
|
|||||||
@@ -1,47 +1,79 @@
|
|||||||
import { useEffect } from "react";
|
import { lazy, Suspense, useEffect, type ComponentType } from "react";
|
||||||
import { Switch, Route, useLocation } from "wouter";
|
import { Switch, Route, Redirect, useLocation } from "wouter";
|
||||||
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
||||||
import { useScreenSize } from "@/hooks/use-screen-size";
|
import { useScreenSize } from "@/hooks/use-screen-size";
|
||||||
import NotFound from "@/pages/not-found";
|
import NotFound from "@/pages/not-found";
|
||||||
import AuthPage from "@/pages/AuthPage";
|
import AuthPage from "@/pages/AuthPage";
|
||||||
import Dashboard from "@/pages/Dashboard";
|
import Dashboard from "@/pages/Dashboard";
|
||||||
import Calendar from "@/pages/Calendar";
|
|
||||||
import Orders from "@/pages/Orders";
|
|
||||||
import Deliveries from "@/pages/Deliveries";
|
|
||||||
import Suppliers from "@/pages/Suppliers";
|
|
||||||
import Groups from "@/pages/Groups";
|
|
||||||
import Users from "@/pages/Users";
|
|
||||||
import BLReconciliation from "@/pages/BLReconciliation";
|
|
||||||
import Publicities from "@/pages/Publicities";
|
|
||||||
|
|
||||||
import NocoDBConfig from "@/pages/NocoDBConfig";
|
|
||||||
import DatabaseDebug from "@/pages/DatabaseDebug";
|
|
||||||
import CustomerOrders from "@/pages/CustomerOrders";
|
|
||||||
|
|
||||||
import DlcPage from "@/pages/DlcPage";
|
|
||||||
import BackupManager from "@/pages/BackupManager";
|
|
||||||
import Utilities from "@/pages/Utilities";
|
|
||||||
import Tasks from "@/pages/Tasks";
|
|
||||||
import SavTickets from "@/pages/SavTickets";
|
|
||||||
import Avoirs from "@/pages/Avoirs";
|
|
||||||
import WeatherSettings from "@/pages/WeatherSettings";
|
|
||||||
import Analytics from "@/pages/Analytics";
|
|
||||||
import PaymentSchedulePage from "@/pages/PaymentSchedulePage";
|
|
||||||
import Contacts from "@/pages/Contacts";
|
|
||||||
import Layout from "@/components/Layout";
|
import Layout from "@/components/Layout";
|
||||||
|
|
||||||
// Mobile pages
|
// Mobile : coque et page d'accueil chargées immédiatement
|
||||||
import MobileApp from "@/pages/mobile/MobileApp";
|
import MobileApp from "@/pages/mobile/MobileApp";
|
||||||
import MobileDashboardPage from "@/pages/mobile/DashboardPage";
|
import MobileDashboardPage from "@/pages/mobile/DashboardPage";
|
||||||
import MobileOrdersPage from "@/pages/mobile/OrdersPage";
|
import MobileLayout from "@/pages/mobile/MobileLayout";
|
||||||
import MobileDeliveriesPage from "@/pages/mobile/DeliveriesPage";
|
|
||||||
import MobileCalendarPage from "@/pages/mobile/CalendarPage";
|
// Les autres pages sont chargées à la demande (un fichier JS par page) : l'écran
|
||||||
import MobileTasksPage from "@/pages/mobile/TasksPage";
|
// de connexion et le tableau de bord n'attendent plus le code de toutes les pages
|
||||||
import MobilePublicitiesPage from "@/pages/mobile/PublicitiesPage";
|
// ni la bibliothèque de graphiques. Le <Suspense> est placé dans la zone de
|
||||||
import MobileCustomerOrdersPage from "@/pages/mobile/CustomerOrdersPage";
|
// contenu de Layout et de MobileApp, le menu et l'en-tête restent affichés.
|
||||||
import MobileDlcPage from "@/pages/mobile/DlcPage";
|
const Calendar = lazy(() => import("@/pages/Calendar"));
|
||||||
import MobileSavPage from "@/pages/mobile/SavPage";
|
const Orders = lazy(() => import("@/pages/Orders"));
|
||||||
import MobileAvoirsPage from "@/pages/mobile/AvoirsPage";
|
const Deliveries = lazy(() => import("@/pages/Deliveries"));
|
||||||
|
const Suppliers = lazy(() => import("@/pages/Suppliers"));
|
||||||
|
const Groups = lazy(() => import("@/pages/Groups"));
|
||||||
|
const Users = lazy(() => import("@/pages/Users"));
|
||||||
|
const BLReconciliation = lazy(() => import("@/pages/BLReconciliation"));
|
||||||
|
const Publicities = lazy(() => import("@/pages/Publicities"));
|
||||||
|
const CustomerOrders = lazy(() => import("@/pages/CustomerOrders"));
|
||||||
|
const DlcPage = lazy(() => import("@/pages/DlcPage"));
|
||||||
|
const Utilities = lazy(() => import("@/pages/Utilities"));
|
||||||
|
const Tasks = lazy(() => import("@/pages/Tasks"));
|
||||||
|
const SavTickets = lazy(() => import("@/pages/SavTickets"));
|
||||||
|
const Avoirs = lazy(() => import("@/pages/Avoirs"));
|
||||||
|
const Analytics = lazy(() => import("@/pages/Analytics"));
|
||||||
|
const PaymentSchedulePage = lazy(() => import("@/pages/PaymentSchedulePage"));
|
||||||
|
const Contacts = lazy(() => import("@/pages/Contacts"));
|
||||||
|
|
||||||
|
// Pages mobiles chargées à la demande
|
||||||
|
const MobileOrdersPage = lazy(() => import("@/pages/mobile/OrdersPage"));
|
||||||
|
const MobileDeliveriesPage = lazy(() => import("@/pages/mobile/DeliveriesPage"));
|
||||||
|
const MobileCalendarPage = lazy(() => import("@/pages/mobile/CalendarPage"));
|
||||||
|
const MobileTasksPage = lazy(() => import("@/pages/mobile/TasksPage"));
|
||||||
|
const MobilePublicitiesPage = lazy(() => import("@/pages/mobile/PublicitiesPage"));
|
||||||
|
const MobileCustomerOrdersPage = lazy(() => import("@/pages/mobile/CustomerOrdersPage"));
|
||||||
|
const MobileDlcPage = lazy(() => import("@/pages/mobile/DlcPage"));
|
||||||
|
const MobileSavPage = lazy(() => import("@/pages/mobile/SavPage"));
|
||||||
|
const MobileAvoirsPage = lazy(() => import("@/pages/mobile/AvoirsPage"));
|
||||||
|
|
||||||
|
// Pages sans version mobile : affichées dans le cadre téléphone (en-tête, barre
|
||||||
|
// du bas et menu) pour ne jamais laisser une page sans navigation
|
||||||
|
function withMobileFrame(Page: ComponentType) {
|
||||||
|
return function MobileFramedPage() {
|
||||||
|
return (
|
||||||
|
<MobileLayout>
|
||||||
|
<div className="p-3">
|
||||||
|
<Suspense fallback={
|
||||||
|
<div className="flex items-center justify-center py-16">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
<Page />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
</MobileLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const MobileFramedContacts = withMobileFrame(Contacts);
|
||||||
|
const MobileFramedSuppliers = withMobileFrame(Suppliers);
|
||||||
|
const MobileFramedGroups = withMobileFrame(Groups);
|
||||||
|
const MobileFramedUsers = withMobileFrame(Users);
|
||||||
|
const MobileFramedBLReconciliation = withMobileFrame(BLReconciliation);
|
||||||
|
const MobileFramedUtilities = withMobileFrame(Utilities);
|
||||||
|
const MobileFramedAnalytics = withMobileFrame(Analytics);
|
||||||
|
const MobileFramedPaymentSchedule = withMobileFrame(PaymentSchedulePage);
|
||||||
|
const MobileFramedNotFound = withMobileFrame(NotFound);
|
||||||
|
|
||||||
// Helper component to handle redirection
|
// Helper component to handle redirection
|
||||||
const RedirectToAuth = () => {
|
const RedirectToAuth = () => {
|
||||||
@@ -53,28 +85,9 @@ const RedirectToAuth = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function RouterProduction() {
|
function RouterProduction() {
|
||||||
const { isAuthenticated, isLoading, user, environment, error } = useAuthUnified();
|
const { isAuthenticated, isLoading, user } = useAuthUnified();
|
||||||
const { isMobile } = useScreenSize();
|
const { isMobile } = useScreenSize();
|
||||||
|
|
||||||
// Debug uniquement en développement
|
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
console.log('🔍 RouterProduction Debug:', {
|
|
||||||
environment,
|
|
||||||
isAuthenticated,
|
|
||||||
isLoading,
|
|
||||||
hasUser: !!user,
|
|
||||||
userId: user?.id,
|
|
||||||
username: user?.username,
|
|
||||||
isMobile,
|
|
||||||
error: error?.message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Debug minimal basé sur l'environnement
|
|
||||||
if (environment === 'production' && error && import.meta.env.DEV) {
|
|
||||||
console.error('🚨 Production Auth Error:', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Loading state
|
// Loading state
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -89,9 +102,6 @@ function RouterProduction() {
|
|||||||
|
|
||||||
// Not authenticated
|
// Not authenticated
|
||||||
if (!isAuthenticated || !user) {
|
if (!isAuthenticated || !user) {
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
console.log('🔐 Not authenticated, showing auth routes');
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route path="/auth" component={AuthPage} />
|
<Route path="/auth" component={AuthPage} />
|
||||||
@@ -113,33 +123,28 @@ function RouterProduction() {
|
|||||||
<Route path="/deliveries" component={MobileDeliveriesPage} />
|
<Route path="/deliveries" component={MobileDeliveriesPage} />
|
||||||
<Route path="/tasks" component={MobileTasksPage} />
|
<Route path="/tasks" component={MobileTasksPage} />
|
||||||
|
|
||||||
{/* Pages sans version mobile - utiliser version desktop pour l'instant */}
|
{/* Pages sans version mobile : version PC dans le cadre téléphone */}
|
||||||
<Route path="/contacts" component={Contacts} />
|
<Route path="/contacts" component={MobileFramedContacts} />
|
||||||
<Route path="/suppliers" component={Suppliers} />
|
<Route path="/suppliers" component={MobileFramedSuppliers} />
|
||||||
<Route path="/groups" component={Groups} />
|
<Route path="/groups" component={MobileFramedGroups} />
|
||||||
<Route path="/users" component={Users} />
|
<Route path="/users" component={MobileFramedUsers} />
|
||||||
<Route path="/bl-reconciliation" component={BLReconciliation} />
|
<Route path="/bl-reconciliation" component={MobileFramedBLReconciliation} />
|
||||||
<Route path="/publicities" component={MobilePublicitiesPage} />
|
<Route path="/publicities" component={MobilePublicitiesPage} />
|
||||||
<Route path="/customer-orders" component={MobileCustomerOrdersPage} />
|
<Route path="/customer-orders" component={MobileCustomerOrdersPage} />
|
||||||
<Route path="/dlc" component={MobileDlcPage} />
|
<Route path="/dlc" component={MobileDlcPage} />
|
||||||
<Route path="/utilities" component={Utilities} />
|
<Route path="/utilities" component={MobileFramedUtilities} />
|
||||||
<Route path="/sav" component={MobileSavPage} />
|
<Route path="/sav" component={MobileSavPage} />
|
||||||
<Route path="/avoirs" component={MobileAvoirsPage} />
|
<Route path="/avoirs" component={MobileAvoirsPage} />
|
||||||
<Route path="/analytics" component={Analytics} />
|
<Route path="/analytics" component={MobileFramedAnalytics} />
|
||||||
<Route path="/payment-schedule" component={PaymentSchedulePage} />
|
<Route path="/payment-schedule" component={MobileFramedPaymentSchedule} />
|
||||||
|
|
||||||
{/* Redirection /auth vers dashboard */}
|
{/* Redirection /auth vers dashboard (navigation interne, sans rechargement) */}
|
||||||
<Route path="/auth">
|
<Route path="/auth">
|
||||||
{() => {
|
<Redirect to="/dashboard" replace />
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
window.location.href = '/dashboard';
|
|
||||||
}
|
|
||||||
return <MobileDashboardPage />;
|
|
||||||
}}
|
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route path="/" component={MobileDashboardPage} />
|
<Route path="/" component={MobileDashboardPage} />
|
||||||
<Route component={NotFound} />
|
<Route component={MobileFramedNotFound} />
|
||||||
</Switch>
|
</Switch>
|
||||||
</MobileApp>
|
</MobileApp>
|
||||||
);
|
);
|
||||||
@@ -175,17 +180,10 @@ function RouterProduction() {
|
|||||||
<Route path="/database-debug" component={Utilities} />
|
<Route path="/database-debug" component={Utilities} />
|
||||||
<Route path="/weather-settings" component={Utilities} />
|
<Route path="/weather-settings" component={Utilities} />
|
||||||
|
|
||||||
{/* Redirection depuis /auth vers dashboard après authentification */}
|
{/* Redirection depuis /auth vers dashboard après authentification
|
||||||
|
(navigation interne, sans rechargement complet de l'application) */}
|
||||||
<Route path="/auth">
|
<Route path="/auth">
|
||||||
{() => {
|
<Redirect to="/" replace />
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
console.log('🔄 Authenticated user on /auth, redirecting to dashboard');
|
|
||||||
}
|
|
||||||
window.location.href = '/';
|
|
||||||
}
|
|
||||||
return <Dashboard />;
|
|
||||||
}}
|
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route path="/" component={Dashboard} />
|
<Route path="/" component={Dashboard} />
|
||||||
|
|||||||
+158
-853
File diff suppressed because it is too large.
Load diff
@@ -1,8 +1,7 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery, keepPreviousData } from "@tanstack/react-query";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { TrendingUp, Package, Truck, Clock } from "lucide-react";
|
import { TrendingUp, Package, Truck, Clock } from "lucide-react";
|
||||||
import { useStore } from "@/contexts/StoreContext";
|
import { useStore } from "@/contexts/StoreContext";
|
||||||
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
|
||||||
|
|
||||||
interface StatsPanelProps {
|
interface StatsPanelProps {
|
||||||
currentDate?: Date;
|
currentDate?: Date;
|
||||||
@@ -10,14 +9,13 @@ interface StatsPanelProps {
|
|||||||
|
|
||||||
export default function StatsPanel({ currentDate = new Date() }: StatsPanelProps) {
|
export default function StatsPanel({ currentDate = new Date() }: StatsPanelProps) {
|
||||||
const { selectedStoreId } = useStore();
|
const { selectedStoreId } = useStore();
|
||||||
const { user } = useAuthUnified();
|
|
||||||
const year = currentDate.getFullYear();
|
const year = currentDate.getFullYear();
|
||||||
const month = currentDate.getMonth() + 1;
|
const month = currentDate.getMonth() + 1;
|
||||||
|
|
||||||
// Construire l'URL avec les paramètres
|
// Construire l'URL avec les paramètres
|
||||||
const statsUrl = `/api/stats/monthly?year=${year}&month=${month}${selectedStoreId ? `&storeId=${selectedStoreId}` : ''}`;
|
const statsUrl = `/api/stats/monthly?year=${year}&month=${month}${selectedStoreId ? `&storeId=${selectedStoreId}` : ''}`;
|
||||||
|
|
||||||
const { data: stats, isLoading } = useQuery({
|
const { data: stats, isLoading, isPlaceholderData } = useQuery({
|
||||||
queryKey: [statsUrl, selectedStoreId], // Include selectedStoreId in key to force refetch
|
queryKey: [statsUrl, selectedStoreId], // Include selectedStoreId in key to force refetch
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const response = await fetch(statsUrl, {
|
const response = await fetch(statsUrl, {
|
||||||
@@ -30,6 +28,10 @@ export default function StatsPanel({ currentDate = new Date() }: StatsPanelProps
|
|||||||
|
|
||||||
return response.json();
|
return response.json();
|
||||||
},
|
},
|
||||||
|
// Changement de mois : garder les chiffres affichés pendant le chargement au lieu
|
||||||
|
// du squelette. Jamais au changement de magasin (chiffres d'un autre magasin).
|
||||||
|
placeholderData: (previousData, previousQuery) =>
|
||||||
|
previousQuery?.queryKey[1] === selectedStoreId ? keepPreviousData(previousData) : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -58,7 +60,8 @@ export default function StatsPanel({ currentDate = new Date() }: StatsPanelProps
|
|||||||
Statistiques du mois
|
Statistiques du mois
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
{/* Chiffres du mois précédent estompés pendant le chargement du nouveau mois */}
|
||||||
|
<CardContent className={isPlaceholderData ? "opacity-50 transition-opacity" : "transition-opacity"}>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-2xl font-bold text-primary flex items-center justify-center">
|
<div className="text-2xl font-bold text-primary flex items-center justify-center">
|
||||||
|
|||||||
@@ -121,7 +121,6 @@ export default function WeatherWidget() {
|
|||||||
const { data: weather, isLoading, error } = useQuery<WeatherResponse>({
|
const { data: weather, isLoading, error } = useQuery<WeatherResponse>({
|
||||||
queryKey: ['/api/weather/current'],
|
queryKey: ['/api/weather/current'],
|
||||||
refetchInterval: 30 * 60 * 1000, // Refetch every 30 minutes
|
refetchInterval: 30 * 60 * 1000, // Refetch every 30 minutes
|
||||||
retry: 1,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,314 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { Clock, FileUp, Send } from "lucide-react";
|
||||||
|
|
||||||
|
// URL utilisée quand aucun webhook BAP n'est configuré dans les paramètres
|
||||||
|
const DEFAULT_BAP_WEBHOOK_URL = "https://workflow.ffnancy.fr/webhook/a3d03176-b72f-412d-8fb9-f920b9fbab4d";
|
||||||
|
|
||||||
|
interface BapUploadModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Envoi d'un bon à payer (PDF) au workflow configuré, avec fenêtre d'attente
|
||||||
|
export default function BapUploadModal({ open, onOpenChange }: BapUploadModalProps) {
|
||||||
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||||
|
const [selectedRecipient, setSelectedRecipient] = useState<string>('');
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
const [showWaitingModal, setShowWaitingModal] = useState(false);
|
||||||
|
const [processingSeconds, setProcessingSeconds] = useState(0);
|
||||||
|
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const stopProcessingTimer = () => {
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearInterval(timerRef.current);
|
||||||
|
timerRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => stopProcessingTimer, []);
|
||||||
|
|
||||||
|
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (file && file.type === 'application/pdf') {
|
||||||
|
setSelectedFile(file);
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: "Erreur",
|
||||||
|
description: "Veuillez sélectionner un fichier PDF",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startProcessingTimer = () => {
|
||||||
|
stopProcessingTimer();
|
||||||
|
setProcessingSeconds(0);
|
||||||
|
timerRef.current = setInterval(() => {
|
||||||
|
setProcessingSeconds(prev => {
|
||||||
|
if (prev >= 60) {
|
||||||
|
stopProcessingTimer();
|
||||||
|
return 60;
|
||||||
|
}
|
||||||
|
return prev + 1;
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseWaitingModal = () => {
|
||||||
|
setShowWaitingModal(false);
|
||||||
|
setProcessingSeconds(0);
|
||||||
|
stopProcessingTimer();
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearSelectedFile = () => {
|
||||||
|
setSelectedFile(null);
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSendBap = async () => {
|
||||||
|
if (!selectedFile || !selectedRecipient) {
|
||||||
|
toast({
|
||||||
|
title: "Erreur",
|
||||||
|
description: "Veuillez sélectionner un fichier PDF et un destinataire",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Récupérer l'URL webhook configurée
|
||||||
|
let webhookUrl = '';
|
||||||
|
try {
|
||||||
|
const configResponse = await fetch('/api/webhook-bap-config', {
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (configResponse.ok) {
|
||||||
|
const config = await configResponse.json();
|
||||||
|
webhookUrl = config?.webhookUrl;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Impossible de récupérer la config webhook, utilisation URL par défaut');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!webhookUrl) {
|
||||||
|
webhookUrl = DEFAULT_BAP_WEBHOOK_URL;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fermer la fenêtre de sélection et ouvrir celle d'attente
|
||||||
|
onOpenChange(false);
|
||||||
|
setShowWaitingModal(true);
|
||||||
|
setIsUploading(true);
|
||||||
|
startProcessingTimer();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', selectedFile);
|
||||||
|
formData.append('recipient', selectedRecipient);
|
||||||
|
formData.append('type', 'BAP');
|
||||||
|
formData.append('fileName', selectedFile.name);
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 60000); // 60 secondes
|
||||||
|
|
||||||
|
const response = await fetch(webhookUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Erreur ${response.status}: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleCloseWaitingModal();
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Succès",
|
||||||
|
description: "Fichier BAP envoyé avec succès",
|
||||||
|
});
|
||||||
|
|
||||||
|
clearSelectedFile();
|
||||||
|
setSelectedRecipient('');
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
handleCloseWaitingModal();
|
||||||
|
|
||||||
|
let errorMessage = "Impossible d'envoyer le fichier BAP";
|
||||||
|
if (error.name === 'AbortError') {
|
||||||
|
errorMessage = "Le traitement a pris trop de temps (timeout de 60 secondes)";
|
||||||
|
} else if (error.message) {
|
||||||
|
errorMessage = error.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Erreur",
|
||||||
|
description: errorMessage,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Réouvrir la fenêtre de sélection en cas d'erreur
|
||||||
|
onOpenChange(true);
|
||||||
|
} finally {
|
||||||
|
setIsUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<FileUp className="h-5 w-5 text-blue-600" />
|
||||||
|
Envoyer un bon à payer (BAP)
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-sm font-semibold text-gray-800">Fichier PDF</Label>
|
||||||
|
|
||||||
|
{!selectedFile ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full border-2 border-dashed border-blue-300 rounded-xl p-8 bg-blue-50 hover:bg-blue-100 transition-colors group"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<div className="text-center space-y-4">
|
||||||
|
<div className="mx-auto h-16 w-16 bg-blue-600 rounded-full flex items-center justify-center group-hover:bg-blue-700 transition-colors">
|
||||||
|
<FileUp className="h-8 w-8 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-lg font-semibold text-gray-800 mb-2">
|
||||||
|
Choisir un fichier PDF
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
Cliquez pour parcourir vos fichiers
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="border-2 border-green-300 rounded-xl p-6 bg-green-50">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className="h-12 w-12 bg-green-600 rounded-full flex items-center justify-center">
|
||||||
|
<FileUp className="h-6 w-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-semibold text-green-800 truncate">{selectedFile.name}</p>
|
||||||
|
<p className="text-sm text-green-700">
|
||||||
|
{(selectedFile.size / 1024 / 1024).toFixed(2)} Mo • PDF
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={clearSelectedFile}
|
||||||
|
className="border-green-300 text-green-700 hover:bg-green-100"
|
||||||
|
>
|
||||||
|
Changer
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".pdf"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-sm font-semibold text-gray-800">Envoyer à</Label>
|
||||||
|
<Select value={selectedRecipient} onValueChange={setSelectedRecipient}>
|
||||||
|
<SelectTrigger className="h-12 border-2 border-gray-200 rounded-lg hover:border-blue-300 transition-colors">
|
||||||
|
<SelectValue placeholder="Choisir le destinataire" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="Laurie" className="py-3">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="h-8 w-8 bg-purple-100 rounded-full flex items-center justify-center">
|
||||||
|
<span className="text-sm font-medium text-purple-600">L</span>
|
||||||
|
</div>
|
||||||
|
<span className="font-medium">Laurie</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="Jeremy" className="py-3">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="h-8 w-8 bg-blue-100 rounded-full flex items-center justify-center">
|
||||||
|
<span className="text-sm font-medium text-blue-600">J</span>
|
||||||
|
</div>
|
||||||
|
<span className="font-medium">Jeremy</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-4 border-t border-gray-200">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
className="flex-1 h-12 border-2 border-gray-200 hover:border-gray-300"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSendBap}
|
||||||
|
disabled={!selectedFile || !selectedRecipient || isUploading}
|
||||||
|
className="flex-1 h-12 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-300 text-white font-medium"
|
||||||
|
>
|
||||||
|
{isUploading ? (
|
||||||
|
<>
|
||||||
|
<div className="animate-spin h-4 w-4 border-2 border-white border-t-transparent rounded-full mr-2"></div>
|
||||||
|
Envoi en cours...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Send className="h-4 w-4 mr-2" />
|
||||||
|
Envoyer le BAP
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Fenêtre d'attente pendant l'envoi */}
|
||||||
|
<Dialog open={showWaitingModal} onOpenChange={() => { }}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Clock className="h-5 w-5" />
|
||||||
|
Traitement en cours...
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex flex-col items-center space-y-4 text-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||||
|
<div className="text-sm text-gray-600">
|
||||||
|
Envoi du fichier vers le workflow...
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
{processingSeconds < 60
|
||||||
|
? `${processingSeconds}s écoulées`
|
||||||
|
: '60s+ écoulées'
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useMemo } from "react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -15,6 +15,9 @@ import { format } from "date-fns";
|
|||||||
import { safeFormat } from "@/lib/dateUtils";
|
import { safeFormat } from "@/lib/dateUtils";
|
||||||
import type { Group, Supplier, OrderWithRelations } from "@shared/schema";
|
import type { Group, Supplier, OrderWithRelations } from "@shared/schema";
|
||||||
|
|
||||||
|
// Référence stable tant que les magasins ne sont pas chargés
|
||||||
|
const NO_GROUPS: Group[] = [];
|
||||||
|
|
||||||
interface CreateDeliveryModalProps {
|
interface CreateDeliveryModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -45,20 +48,29 @@ export default function CreateDeliveryModal({
|
|||||||
queryKey: ['/api/suppliers'],
|
queryKey: ['/api/suppliers'],
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: groupsData = [] } = useQuery<Group[]>({
|
const { data: groupsData = NO_GROUPS } = useQuery<Group[]>({
|
||||||
queryKey: ['/api/groups'],
|
queryKey: ['/api/groups'],
|
||||||
});
|
});
|
||||||
|
|
||||||
// Filtrer les groupes selon le magasin sélectionné pour les admins
|
// Filtrer les groupes selon le magasin sélectionné pour les admins
|
||||||
const groups = Array.isArray(groupsData) ? (
|
// (mémorisé : la liste est une dépendance de l'effet ci-dessous, qui
|
||||||
|
// tournait sinon à chaque rendu, donc à chaque frappe dans le formulaire)
|
||||||
|
const groups = useMemo(() => Array.isArray(groupsData) ? (
|
||||||
user?.role === 'admin' && selectedStoreId
|
user?.role === 'admin' && selectedStoreId
|
||||||
? groupsData.filter(g => g.id === selectedStoreId)
|
? groupsData.filter(g => g.id === selectedStoreId)
|
||||||
: groupsData
|
: groupsData
|
||||||
) : [];
|
) : NO_GROUPS, [groupsData, user?.role, selectedStoreId]);
|
||||||
|
|
||||||
// Filtrer les commandes par fournisseur sélectionné
|
// Commandes du magasin du formulaire uniquement (paramètre storeId, dont
|
||||||
|
// l'accès est contrôlé par le serveur) au lieu de tout l'historique de tous
|
||||||
|
// les magasins ; filtrées ensuite par fournisseur
|
||||||
const { data: allOrders = [] } = useQuery<OrderWithRelations[]>({
|
const { data: allOrders = [] } = useQuery<OrderWithRelations[]>({
|
||||||
queryKey: ['/api/orders'],
|
queryKey: ['/api/orders', 'store', formData.groupId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const data = await apiRequest(`/api/orders?storeId=${formData.groupId}`);
|
||||||
|
return Array.isArray(data) ? data : [];
|
||||||
|
},
|
||||||
|
enabled: !!formData.groupId,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Filtrer les commandes par fournisseur ET magasin sélectionnés - montrer toutes les commandes non livrées
|
// Filtrer les commandes par fournisseur ET magasin sélectionnés - montrer toutes les commandes non livrées
|
||||||
@@ -74,21 +86,8 @@ export default function CreateDeliveryModal({
|
|||||||
|
|
||||||
// Auto-sélectionner le magasin selon les règles
|
// Auto-sélectionner le magasin selon les règles
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log('🏪 CreateDeliveryModal - Store selection effect:', {
|
|
||||||
groupsLength: groups.length,
|
|
||||||
currentFormGroupId: formData.groupId,
|
|
||||||
selectedStoreId,
|
|
||||||
userRole: user?.role,
|
|
||||||
allGroups: groups.map(g => ({ id: g.id, name: g.name })),
|
|
||||||
filteredGroups: groups.length
|
|
||||||
});
|
|
||||||
|
|
||||||
// Reset le formulaire si le magasin sélectionné change
|
// Reset le formulaire si le magasin sélectionné change
|
||||||
if (user?.role === 'admin' && selectedStoreId && formData.groupId && formData.groupId !== selectedStoreId.toString()) {
|
if (user?.role === 'admin' && selectedStoreId && formData.groupId && formData.groupId !== selectedStoreId.toString()) {
|
||||||
console.log('🔄 Resetting delivery form because store changed:', {
|
|
||||||
currentGroupId: formData.groupId,
|
|
||||||
newStoreId: selectedStoreId.toString()
|
|
||||||
});
|
|
||||||
setFormData(prev => ({ ...prev, groupId: "", orderId: "" }));
|
setFormData(prev => ({ ...prev, groupId: "", orderId: "" }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -103,21 +102,13 @@ export default function CreateDeliveryModal({
|
|||||||
} else {
|
} else {
|
||||||
defaultGroupId = groups[0].id.toString();
|
defaultGroupId = groups[0].id.toString();
|
||||||
}
|
}
|
||||||
console.log('🏪 Admin delivery store selection:', {
|
|
||||||
selectedStoreId,
|
|
||||||
defaultGroupId,
|
|
||||||
firstGroupId: groups[0].id,
|
|
||||||
groupsAvailable: groups.map(g => g.name)
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
// Pour les autres rôles : prendre le premier magasin attribué
|
// Pour les autres rôles : prendre le premier magasin attribué
|
||||||
// (La logique existante filtre déjà les groupes selon les permissions)
|
// (La logique existante filtre déjà les groupes selon les permissions)
|
||||||
defaultGroupId = groups[0].id.toString();
|
defaultGroupId = groups[0].id.toString();
|
||||||
console.log('🏪 Non-admin delivery store selection:', { defaultGroupId, firstGroupId: groups[0].id });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (defaultGroupId) {
|
if (defaultGroupId) {
|
||||||
console.log('🏪 Setting default group ID for delivery:', defaultGroupId, 'for group:', groups.find(g => g.id.toString() === defaultGroupId)?.name);
|
|
||||||
setFormData(prev => ({ ...prev, groupId: defaultGroupId }));
|
setFormData(prev => ({ ...prev, groupId: defaultGroupId }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -148,7 +139,6 @@ export default function CreateDeliveryModal({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Invalider toutes les variantes de queryKey pour assurer cohérence
|
// Invalider toutes les variantes de queryKey pour assurer cohérence
|
||||||
console.log('🚚 Delivery created, clearing cache for consistency');
|
|
||||||
queryClient.invalidateQueries({ predicate: (query) => {
|
queryClient.invalidateQueries({ predicate: (query) => {
|
||||||
const key = query.queryKey;
|
const key = query.queryKey;
|
||||||
const firstKey = key[0]?.toString() || '';
|
const firstKey = key[0]?.toString() || '';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useMemo } from "react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -14,6 +14,9 @@ import { isUnauthorizedError } from "@/lib/authUtils";
|
|||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import type { Group, Supplier } from "@shared/schema";
|
import type { Group, Supplier } from "@shared/schema";
|
||||||
|
|
||||||
|
// Référence stable tant que les magasins ne sont pas chargés
|
||||||
|
const NO_GROUPS: Group[] = [];
|
||||||
|
|
||||||
interface CreateOrderModalProps {
|
interface CreateOrderModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -41,34 +44,23 @@ export default function CreateOrderModal({
|
|||||||
queryKey: ['/api/suppliers'],
|
queryKey: ['/api/suppliers'],
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: groupsData = [] } = useQuery<Group[]>({
|
const { data: groupsData = NO_GROUPS } = useQuery<Group[]>({
|
||||||
queryKey: ['/api/groups'],
|
queryKey: ['/api/groups'],
|
||||||
});
|
});
|
||||||
|
|
||||||
// Filtrer les groupes selon le magasin sélectionné pour les admins
|
// Filtrer les groupes selon le magasin sélectionné pour les admins
|
||||||
const groups = Array.isArray(groupsData) ? (
|
// (mémorisé : la liste est une dépendance de l'effet ci-dessous, qui
|
||||||
|
// tournait sinon à chaque rendu, donc à chaque frappe dans le formulaire)
|
||||||
|
const groups = useMemo(() => Array.isArray(groupsData) ? (
|
||||||
user?.role === 'admin' && selectedStoreId
|
user?.role === 'admin' && selectedStoreId
|
||||||
? groupsData.filter(g => g.id === selectedStoreId)
|
? groupsData.filter(g => g.id === selectedStoreId)
|
||||||
: groupsData
|
: groupsData
|
||||||
) : [];
|
) : NO_GROUPS, [groupsData, user?.role, selectedStoreId]);
|
||||||
|
|
||||||
// Auto-sélectionner le magasin selon les règles
|
// Auto-sélectionner le magasin selon les règles
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log('🏪 CreateOrderModal - Store selection effect:', {
|
|
||||||
groupsLength: groups.length,
|
|
||||||
currentFormGroupId: formData.groupId,
|
|
||||||
selectedStoreId,
|
|
||||||
userRole: user?.role,
|
|
||||||
allGroups: groups.map(g => ({ id: g.id, name: g.name })),
|
|
||||||
filteredGroups: groups.length
|
|
||||||
});
|
|
||||||
|
|
||||||
// Reset le formulaire si le magasin sélectionné change
|
// Reset le formulaire si le magasin sélectionné change
|
||||||
if (user?.role === 'admin' && selectedStoreId && formData.groupId && formData.groupId !== selectedStoreId.toString()) {
|
if (user?.role === 'admin' && selectedStoreId && formData.groupId && formData.groupId !== selectedStoreId.toString()) {
|
||||||
console.log('🔄 Resetting form because store changed:', {
|
|
||||||
currentGroupId: formData.groupId,
|
|
||||||
newStoreId: selectedStoreId.toString()
|
|
||||||
});
|
|
||||||
setFormData(prev => ({ ...prev, groupId: "" }));
|
setFormData(prev => ({ ...prev, groupId: "" }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -83,20 +75,12 @@ export default function CreateOrderModal({
|
|||||||
} else {
|
} else {
|
||||||
defaultGroupId = groups[0].id.toString();
|
defaultGroupId = groups[0].id.toString();
|
||||||
}
|
}
|
||||||
console.log('🏪 Admin store selection:', {
|
|
||||||
selectedStoreId,
|
|
||||||
defaultGroupId,
|
|
||||||
firstGroupId: groups[0].id,
|
|
||||||
groupsAvailable: groups.map(g => g.name)
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
// Pour les autres rôles : prendre le premier magasin attribué
|
// Pour les autres rôles : prendre le premier magasin attribué
|
||||||
defaultGroupId = groups[0].id.toString();
|
defaultGroupId = groups[0].id.toString();
|
||||||
console.log('🏪 Non-admin store selection:', { defaultGroupId, firstGroupId: groups[0].id });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (defaultGroupId) {
|
if (defaultGroupId) {
|
||||||
console.log('🏪 Setting default group ID:', defaultGroupId, 'for group:', groups.find(g => g.id.toString() === defaultGroupId)?.name);
|
|
||||||
setFormData(prev => ({ ...prev, groupId: defaultGroupId }));
|
setFormData(prev => ({ ...prev, groupId: defaultGroupId }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -104,9 +88,7 @@ export default function CreateOrderModal({
|
|||||||
|
|
||||||
const createOrderMutation = useMutation({
|
const createOrderMutation = useMutation({
|
||||||
mutationFn: async (data: any) => {
|
mutationFn: async (data: any) => {
|
||||||
console.log('🚀 Creating order with data:', data);
|
|
||||||
const response = await apiRequest("/api/orders", "POST", data);
|
const response = await apiRequest("/api/orders", "POST", data);
|
||||||
console.log('✅ Order created successfully:', response);
|
|
||||||
return response;
|
return response;
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -114,8 +96,6 @@ export default function CreateOrderModal({
|
|||||||
title: "Succès",
|
title: "Succès",
|
||||||
description: "Commande créée avec succès",
|
description: "Commande créée avec succès",
|
||||||
});
|
});
|
||||||
// Force un nettoyage complet du cache pour éviter incohérences
|
|
||||||
console.log('🆕 Order created, clearing cache for consistency');
|
|
||||||
|
|
||||||
// Invalider toutes les variantes de queryKey pour assurer cohérence
|
// Invalider toutes les variantes de queryKey pour assurer cohérence
|
||||||
queryClient.invalidateQueries({ predicate: (query) => {
|
queryClient.invalidateQueries({ predicate: (query) => {
|
||||||
|
|||||||
@@ -47,8 +47,16 @@ export default function EditDeliveryModal({
|
|||||||
queryKey: ['/api/groups'],
|
queryKey: ['/api/groups'],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Commandes du magasin du formulaire uniquement (paramètre storeId, dont
|
||||||
|
// l'accès est contrôlé par le serveur) au lieu de tout l'historique de tous
|
||||||
|
// les magasins ; même clé que la modale de création
|
||||||
const { data: allOrders = [] } = useQuery<OrderWithRelations[]>({
|
const { data: allOrders = [] } = useQuery<OrderWithRelations[]>({
|
||||||
queryKey: ['/api/orders'],
|
queryKey: ['/api/orders', 'store', formData.groupId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const data = await apiRequest(`/api/orders?storeId=${formData.groupId}`);
|
||||||
|
return Array.isArray(data) ? data : [];
|
||||||
|
},
|
||||||
|
enabled: !!formData.groupId,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Filtrer les commandes par fournisseur sélectionné et groupe - montrer toutes les commandes non livrées du même groupe
|
// Filtrer les commandes par fournisseur sélectionné et groupe - montrer toutes les commandes non livrées du même groupe
|
||||||
|
|||||||
@@ -54,9 +54,9 @@ export default function ValidateDeliveryModal({
|
|||||||
title: "Succès",
|
title: "Succès",
|
||||||
description: "Livraison validée avec succès",
|
description: "Livraison validée avec succès",
|
||||||
});
|
});
|
||||||
// Invalider tous les caches liés aux livraisons
|
// Invalider tous les caches liés aux livraisons et les caches BL/Rapprochement
|
||||||
queryClient.invalidateQueries({ queryKey: ['/api/deliveries'] });
|
// avec toutes les variations de clés, en un seul appel (un second appel sur
|
||||||
// Invalider tous les caches BL/Rapprochement avec toutes les variations de clés
|
// ['/api/deliveries'] relançait le chargement des listes affichées)
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
predicate: (query) =>
|
predicate: (query) =>
|
||||||
query.queryKey[0] === '/api/deliveries/bl' ||
|
query.queryKey[0] === '/api/deliveries/bl' ||
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ import { useState, useEffect } from "react"
|
|||||||
const MOBILE_BREAKPOINT = 768
|
const MOBILE_BREAKPOINT = 768
|
||||||
|
|
||||||
export function useIsMobile() {
|
export function useIsMobile() {
|
||||||
const [isMobile, setIsMobile] = useState<boolean | undefined>(undefined)
|
// Valeur calculée dès le premier rendu (évite un second rendu au montage)
|
||||||
|
const [isMobile, setIsMobile] = useState<boolean | undefined>(() =>
|
||||||
|
typeof window !== "undefined" ? window.innerWidth < MOBILE_BREAKPOINT : undefined
|
||||||
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
||||||
|
|||||||
@@ -14,26 +14,24 @@ const defaultBreakpoints: ScreenBreakpoints = {
|
|||||||
desktop: 1280
|
desktop: 1280
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getScreenSize = (width: number, breakpoints: ScreenBreakpoints): ScreenSize => {
|
||||||
|
if (width < breakpoints.mobile) return 'mobile'
|
||||||
|
if (width <= breakpoints.tablet) return 'tablet' // Inclut 1024px comme tablette
|
||||||
|
return 'desktop'
|
||||||
|
}
|
||||||
|
|
||||||
export function useScreenSize(breakpoints: ScreenBreakpoints = defaultBreakpoints) {
|
export function useScreenSize(breakpoints: ScreenBreakpoints = defaultBreakpoints) {
|
||||||
const [screenSize, setScreenSize] = useState<ScreenSize>('desktop')
|
// Palier calculé dès le premier rendu (pas de passage transitoire par 'desktop')
|
||||||
const [dimensions, setDimensions] = useState({
|
const [screenSize, setScreenSize] = useState<ScreenSize>(() =>
|
||||||
width: typeof window !== 'undefined' ? window.innerWidth : 1200,
|
typeof window !== 'undefined' ? getScreenSize(window.innerWidth, breakpoints) : 'desktop'
|
||||||
height: typeof window !== 'undefined' ? window.innerHeight : 800
|
)
|
||||||
})
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const getScreenSize = (width: number): ScreenSize => {
|
// Seul le palier est stocké : setScreenSize avec la même valeur ne provoque
|
||||||
if (width < breakpoints.mobile) return 'mobile'
|
// pas de re-rendu, donc l'arbre n'est re-rendu que lorsque le palier change
|
||||||
if (width <= breakpoints.tablet) return 'tablet' // Inclut 1024px comme tablette
|
// et non à chaque pixel de redimensionnement
|
||||||
return 'desktop'
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateScreenSize = () => {
|
const updateScreenSize = () => {
|
||||||
const width = window.innerWidth
|
setScreenSize(getScreenSize(window.innerWidth, breakpoints))
|
||||||
const height = window.innerHeight
|
|
||||||
|
|
||||||
setDimensions({ width, height })
|
|
||||||
setScreenSize(getScreenSize(width))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set initial values
|
// Set initial values
|
||||||
@@ -46,6 +44,13 @@ export function useScreenSize(breakpoints: ScreenBreakpoints = defaultBreakpoint
|
|||||||
return () => window.removeEventListener('resize', updateScreenSize)
|
return () => window.removeEventListener('resize', updateScreenSize)
|
||||||
}, [breakpoints])
|
}, [breakpoints])
|
||||||
|
|
||||||
|
// Dimensions lues au moment du rendu (non stockées dans l'état pour ne pas
|
||||||
|
// re-rendre à chaque redimensionnement)
|
||||||
|
const dimensions = {
|
||||||
|
width: typeof window !== 'undefined' ? window.innerWidth : 1200,
|
||||||
|
height: typeof window !== 'undefined' ? window.innerHeight : 800
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
screenSize,
|
screenSize,
|
||||||
dimensions,
|
dimensions,
|
||||||
|
|||||||
@@ -1,28 +1,3 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
// Conservé pour compatibilité : délègue au hook unifié (même cache React Query
|
||||||
|
// partagé, un seul GET /api/user pour toute l'application)
|
||||||
export function useAuth() {
|
export { useAuthUnified as useAuth } from './useAuthUnified';
|
||||||
const { data: user, isLoading, error } = useQuery({
|
|
||||||
queryKey: ["/api/user"],
|
|
||||||
retry: (failureCount, error: any) => {
|
|
||||||
// Ne pas retry si c'est une erreur d'authentification
|
|
||||||
if (error?.message?.includes('401') || error?.message?.includes('Unauthorized')) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return failureCount < 2;
|
|
||||||
},
|
|
||||||
refetchInterval: false,
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
refetchOnMount: true, // Permettre le rechargement au montage
|
|
||||||
refetchOnReconnect: false,
|
|
||||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
|
||||||
gcTime: 10 * 60 * 1000, // 10 minutes
|
|
||||||
enabled: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
user: user || null,
|
|
||||||
isLoading,
|
|
||||||
isAuthenticated: !!user,
|
|
||||||
error,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,70 +1,3 @@
|
|||||||
import { useState, useEffect } from 'react';
|
// Conservé pour compatibilité : délègue au hook unifié (même cache React Query
|
||||||
|
// partagé, un seul GET /api/user pour toute l'application)
|
||||||
// Hook d'authentification spécifique pour la production
|
export { useAuthUnified as useAuthProduction } from './useAuthUnified';
|
||||||
// Utilise fetch direct sans React Query pour éviter les problèmes de cache
|
|
||||||
export function useAuthProduction() {
|
|
||||||
const [user, setUser] = useState<any>(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<any>(null);
|
|
||||||
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
|
||||||
|
|
||||||
const checkAuth = async () => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
console.log('🔄 Production auth check starting...');
|
|
||||||
|
|
||||||
const response = await fetch('/api/user', {
|
|
||||||
credentials: 'include',
|
|
||||||
cache: 'no-cache',
|
|
||||||
headers: {
|
|
||||||
'Accept': 'application/json',
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('🔄 Production auth response:', response.status);
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const userData = await response.json();
|
|
||||||
console.log('✅ Production auth success:', { username: userData?.username, id: userData?.id });
|
|
||||||
setUser(userData);
|
|
||||||
setError(null);
|
|
||||||
} else if (response.status === 401) {
|
|
||||||
console.log('❌ Production auth 401 - user not authenticated');
|
|
||||||
setUser(null);
|
|
||||||
setError(null);
|
|
||||||
} else {
|
|
||||||
throw new Error(`Auth failed: ${response.status}`);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('❌ Production auth error:', err);
|
|
||||||
setError(err);
|
|
||||||
setUser(null);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
console.log('🔄 Production auth check completed');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Authentification au montage et lors des triggers
|
|
||||||
useEffect(() => {
|
|
||||||
checkAuth();
|
|
||||||
}, [refreshTrigger]);
|
|
||||||
|
|
||||||
// Fonction pour rafraîchir l'authentification
|
|
||||||
const refreshAuth = () => {
|
|
||||||
console.log('🔄 RefreshAuth called for production');
|
|
||||||
setRefreshTrigger(prev => prev + 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
user,
|
|
||||||
isLoading,
|
|
||||||
isAuthenticated: !!user,
|
|
||||||
error,
|
|
||||||
refreshAuth,
|
|
||||||
environment: 'production'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,63 +1,3 @@
|
|||||||
import { useState, useEffect } from 'react';
|
// Conservé pour compatibilité : délègue au hook unifié (même cache React Query
|
||||||
|
// partagé, un seul GET /api/user pour toute l'application)
|
||||||
// Hook d'authentification simplifié qui fonctionne dans tous les environnements
|
export { useAuthUnified as useAuthSimple } from './useAuthUnified';
|
||||||
export function useAuthSimple() {
|
|
||||||
const [user, setUser] = useState<any>(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<any>(null);
|
|
||||||
const [refreshCounter, setRefreshCounter] = useState(0);
|
|
||||||
|
|
||||||
// Fonction pour vérifier l'authentification
|
|
||||||
const checkAuth = async () => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
const response = await fetch('/api/user', {
|
|
||||||
credentials: 'include',
|
|
||||||
cache: 'no-cache',
|
|
||||||
headers: {
|
|
||||||
'Accept': 'application/json',
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const userData = await response.json();
|
|
||||||
console.log('✅ Auth check successful:', { username: userData?.username, id: userData?.id });
|
|
||||||
setUser(userData);
|
|
||||||
setError(null);
|
|
||||||
} else if (response.status === 401) {
|
|
||||||
console.log('❌ Auth check 401 - user not authenticated');
|
|
||||||
setUser(null);
|
|
||||||
setError(null);
|
|
||||||
} else {
|
|
||||||
throw new Error(`Auth failed: ${response.status}`);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Auth check error:', err);
|
|
||||||
setError(err);
|
|
||||||
setUser(null);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Fonction pour forcer un refresh de l'authentification
|
|
||||||
const refreshAuth = () => {
|
|
||||||
console.log('🔄 Refreshing authentication...');
|
|
||||||
setRefreshCounter(prev => prev + 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Vérifier l'authentification au montage et lors des refresh
|
|
||||||
useEffect(() => {
|
|
||||||
checkAuth();
|
|
||||||
}, [refreshCounter]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
user,
|
|
||||||
isLoading,
|
|
||||||
error,
|
|
||||||
isAuthenticated: !!user,
|
|
||||||
refreshAuth
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,242 +1,57 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { AUTH_QUERY_KEY, fetchCurrentUser } from "@/lib/queryClient";
|
||||||
|
|
||||||
// Hook d'authentification unifié qui s'adapte automatiquement
|
// Conservé dans le retour du hook pour compatibilité (utilisé pour le débogage)
|
||||||
// En production utilise fetch direct, en développement utilise React Query
|
const environment = import.meta.env.DEV ? 'development' : 'production';
|
||||||
|
|
||||||
|
// Hook d'authentification unique (développement et production) : l'utilisateur
|
||||||
|
// connecté est lu une seule fois via GET /api/user puis partagé par toute
|
||||||
|
// l'application à travers le cache React Query (clé ['/api/user']).
|
||||||
|
// Un 401 donne user = null (non connecté), sans redirection.
|
||||||
export function useAuthUnified() {
|
export function useAuthUnified() {
|
||||||
// Détection d'environnement plus robuste
|
const queryClient = useQueryClient();
|
||||||
const isDevelopment = typeof window !== 'undefined' &&
|
|
||||||
(window.location.hostname === 'localhost' ||
|
|
||||||
window.location.hostname.includes('replit.dev')) &&
|
|
||||||
import.meta.env.DEV === true;
|
|
||||||
|
|
||||||
// Debug logging uniquement en développement
|
const authQuery = useQuery<any>({
|
||||||
if (import.meta.env.DEV) {
|
queryKey: AUTH_QUERY_KEY,
|
||||||
console.log('🔍 Auth Environment Debug:', {
|
queryFn: fetchCurrentUser,
|
||||||
hostname: typeof window !== 'undefined' ? window.location.hostname : 'N/A',
|
staleTime: Infinity,
|
||||||
isDev: import.meta.env.DEV,
|
retry: false,
|
||||||
environment: isDevelopment ? 'development' : 'production'
|
// Après une erreur serveur, ne pas relancer la requête à chaque montage d'un
|
||||||
});
|
// composant (AuthPage) : sinon boucle chargement / page de connexion
|
||||||
}
|
retryOnMount: false,
|
||||||
|
|
||||||
// État pour la version production (fetch direct)
|
|
||||||
const [productionUser, setProductionUser] = useState<any>(null);
|
|
||||||
const [productionLoading, setProductionLoading] = useState(true);
|
|
||||||
const [productionError, setProductionError] = useState<any>(null);
|
|
||||||
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
|
||||||
|
|
||||||
// Hook React Query pour le développement
|
|
||||||
const developmentQuery = useQuery({
|
|
||||||
queryKey: ["/api/user"],
|
|
||||||
retry: (failureCount, error: any) => {
|
|
||||||
if (error?.message?.includes('401') || error?.message?.includes('Unauthorized')) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return failureCount < 2;
|
|
||||||
},
|
|
||||||
refetchInterval: false,
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
refetchOnMount: true,
|
|
||||||
refetchOnReconnect: false,
|
|
||||||
staleTime: 10 * 60 * 1000, // 10 minutes de cache pour l'auth
|
|
||||||
gcTime: 15 * 60 * 1000, // 15 minutes
|
|
||||||
enabled: isDevelopment, // Seulement en développement
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fonction pour rafraîchir l'authentification
|
// Rafraîchissement en arrière-plan : tous les composants abonnés reçoivent
|
||||||
const refreshAuth = () => {
|
// le nouvel utilisateur quand la réponse arrive
|
||||||
if (import.meta.env.DEV) {
|
const refreshAuth = useCallback(() => {
|
||||||
console.log('🔄 RefreshAuth called, isDevelopment:', isDevelopment);
|
return queryClient.invalidateQueries({ queryKey: AUTH_QUERY_KEY });
|
||||||
}
|
}, [queryClient]);
|
||||||
if (!isDevelopment) {
|
|
||||||
if (import.meta.env.DEV) {
|
// Rechargement immédiat (après connexion) : attend la réponse du serveur,
|
||||||
console.log('🔄 Triggering production auth refresh');
|
// met à jour le cache partagé et renvoie l'utilisateur (ou null)
|
||||||
}
|
const forceAuthRefresh = useCallback(async () => {
|
||||||
setRefreshTrigger(prev => {
|
try {
|
||||||
const newValue = prev + 1;
|
return await queryClient.fetchQuery({
|
||||||
if (import.meta.env.DEV) {
|
queryKey: AUTH_QUERY_KEY,
|
||||||
console.log('🔄 Production refresh trigger updated:', prev, '->', newValue);
|
queryFn: fetchCurrentUser,
|
||||||
}
|
staleTime: 0,
|
||||||
return newValue;
|
|
||||||
});
|
});
|
||||||
} else {
|
} catch (error) {
|
||||||
if (import.meta.env.DEV) {
|
console.error("Erreur lors du rafraîchissement de l'authentification:", error);
|
||||||
console.log('🔄 Development mode - using React Query refresh');
|
return null;
|
||||||
}
|
|
||||||
developmentQuery.refetch();
|
|
||||||
}
|
}
|
||||||
|
}, [queryClient]);
|
||||||
|
|
||||||
|
const user = authQuery.data ?? null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
isLoading: authQuery.isLoading,
|
||||||
|
isAuthenticated: !!user,
|
||||||
|
error: authQuery.error,
|
||||||
|
refreshAuth,
|
||||||
|
forceAuthRefresh,
|
||||||
|
environment
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fonction pour rafraîchir de manière synchrone (pour après login)
|
|
||||||
const forceAuthRefresh = async () => {
|
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
console.log('🔄 ForceAuthRefresh called, isDevelopment:', isDevelopment);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isDevelopment) {
|
|
||||||
// En production, faire un fetch immédiat et forcer un re-render
|
|
||||||
try {
|
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
console.log('🔄 Production force refresh - fetching user data');
|
|
||||||
}
|
|
||||||
setProductionLoading(true);
|
|
||||||
|
|
||||||
const response = await fetch('/api/user', {
|
|
||||||
credentials: 'include',
|
|
||||||
cache: 'no-cache',
|
|
||||||
headers: {
|
|
||||||
'Accept': 'application/json',
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const userData = await response.json();
|
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
console.log('✅ Production force refresh success:', { username: userData?.username, id: userData?.id });
|
|
||||||
}
|
|
||||||
setProductionUser(userData);
|
|
||||||
setProductionError(null);
|
|
||||||
setProductionLoading(false);
|
|
||||||
|
|
||||||
// Forcer un trigger de refresh pour déclencher les re-renders
|
|
||||||
setRefreshTrigger(prev => prev + 1);
|
|
||||||
|
|
||||||
return userData;
|
|
||||||
} else {
|
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
console.log('❌ Production force refresh failed:', response.status);
|
|
||||||
}
|
|
||||||
setProductionUser(null);
|
|
||||||
setProductionError(null);
|
|
||||||
setProductionLoading(false);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
console.error('❌ Production force refresh error:', error);
|
|
||||||
}
|
|
||||||
setProductionError(error);
|
|
||||||
setProductionUser(null);
|
|
||||||
setProductionLoading(false);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// En développement, forcer un refetch avec invalidation du cache
|
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
console.log('🔄 Development mode - invalidating cache and refetching');
|
|
||||||
}
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['/api/user'] });
|
|
||||||
|
|
||||||
// Attendre un court délai pour la propagation
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 50));
|
|
||||||
|
|
||||||
const result = await developmentQuery.refetch();
|
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
console.log('🔄 Development refetch result:', {
|
|
||||||
success: result.isSuccess,
|
|
||||||
hasData: !!result.data,
|
|
||||||
userId: (result.data as any)?.id
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return result.data;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Authentification production (fetch direct)
|
|
||||||
useEffect(() => {
|
|
||||||
if (isDevelopment) return; // Ne pas exécuter en développement
|
|
||||||
|
|
||||||
let isMounted = true;
|
|
||||||
|
|
||||||
const checkAuth = async () => {
|
|
||||||
try {
|
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
console.log('🔄 Production auth check starting, refreshTrigger:', refreshTrigger);
|
|
||||||
}
|
|
||||||
setProductionLoading(true);
|
|
||||||
|
|
||||||
const response = await fetch('/api/user', {
|
|
||||||
credentials: 'include',
|
|
||||||
cache: 'no-cache',
|
|
||||||
headers: {
|
|
||||||
'Accept': 'application/json',
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
console.log('🔄 Production auth response:', response.status);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isMounted) return;
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const userData = await response.json();
|
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
console.log('✅ Production auth success:', { username: userData?.username, id: userData?.id });
|
|
||||||
}
|
|
||||||
if (isMounted) {
|
|
||||||
setProductionUser(userData);
|
|
||||||
setProductionError(null);
|
|
||||||
}
|
|
||||||
} else if (response.status === 401) {
|
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
console.log('❌ Production auth 401 - user not authenticated');
|
|
||||||
}
|
|
||||||
if (isMounted) {
|
|
||||||
setProductionUser(null);
|
|
||||||
setProductionError(null);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw new Error(`Auth failed: ${response.status}`);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
console.error('Production auth error:', err);
|
|
||||||
}
|
|
||||||
if (isMounted) {
|
|
||||||
setProductionError(err);
|
|
||||||
setProductionUser(null);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (isMounted) {
|
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
console.log('🔄 Production auth check complete, loading set to false');
|
|
||||||
}
|
|
||||||
setProductionLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
checkAuth();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
isMounted = false;
|
|
||||||
};
|
|
||||||
}, [isDevelopment, refreshTrigger]); // Ajout du refreshTrigger
|
|
||||||
|
|
||||||
// Retourner les bonnes données selon l'environnement
|
|
||||||
if (isDevelopment) {
|
|
||||||
return {
|
|
||||||
user: developmentQuery.data || null,
|
|
||||||
isLoading: developmentQuery.isLoading,
|
|
||||||
isAuthenticated: !!developmentQuery.data,
|
|
||||||
error: developmentQuery.error,
|
|
||||||
refreshAuth: refreshAuth,
|
|
||||||
forceAuthRefresh: forceAuthRefresh,
|
|
||||||
environment: 'development'
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
return {
|
|
||||||
user: productionUser,
|
|
||||||
isLoading: productionLoading,
|
|
||||||
isAuthenticated: !!productionUser,
|
|
||||||
error: productionError,
|
|
||||||
refreshAuth: refreshAuth,
|
|
||||||
forceAuthRefresh: forceAuthRefresh,
|
|
||||||
environment: 'production'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import {
|
||||||
|
House,
|
||||||
|
ListTodo,
|
||||||
|
CalendarClock,
|
||||||
|
ShoppingCart,
|
||||||
|
Wrench,
|
||||||
|
Package,
|
||||||
|
Truck,
|
||||||
|
FileCheck,
|
||||||
|
Receipt,
|
||||||
|
CreditCard,
|
||||||
|
CalendarDays,
|
||||||
|
Megaphone,
|
||||||
|
BookUser,
|
||||||
|
ChartLine,
|
||||||
|
UserCog,
|
||||||
|
Store,
|
||||||
|
Building2,
|
||||||
|
Settings,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
// Configuration unique du menu de navigation (docs/PLAN-OPTIMISATION-WEBUI.md, P1 § 2.1).
|
||||||
|
// Chaque icône n'apparaît qu'une fois ; les adresses sont inchangées.
|
||||||
|
|
||||||
|
export type Role = "admin" | "directeur" | "manager" | "employee";
|
||||||
|
|
||||||
|
const ALL_ROLES: Role[] = ["admin", "directeur", "manager", "employee"];
|
||||||
|
|
||||||
|
export interface NavItem {
|
||||||
|
path: string;
|
||||||
|
label: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
roles: Role[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NavSection {
|
||||||
|
// Pas de titre pour les entrées Accueil et Calendrier, affichées en tête
|
||||||
|
title?: string;
|
||||||
|
items: NavItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NAV_SECTIONS: NavSection[] = [
|
||||||
|
{
|
||||||
|
items: [
|
||||||
|
{ path: "/", label: "Accueil", icon: House, roles: ALL_ROLES },
|
||||||
|
{ path: "/calendar", label: "Calendrier", icon: CalendarDays, roles: ALL_ROLES },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Au quotidien",
|
||||||
|
items: [
|
||||||
|
{ path: "/tasks", label: "Tâches", icon: ListTodo, roles: ALL_ROLES },
|
||||||
|
{ path: "/dlc", label: "DLC", icon: CalendarClock, roles: ALL_ROLES },
|
||||||
|
{ path: "/customer-orders", label: "Commandes clients", icon: ShoppingCart, roles: ALL_ROLES },
|
||||||
|
{ path: "/sav", label: "SAV", icon: Wrench, roles: ALL_ROLES },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Fournisseurs",
|
||||||
|
items: [
|
||||||
|
{ path: "/orders", label: "Commandes fournisseurs", icon: Package, roles: ["admin", "directeur", "manager"] },
|
||||||
|
{ path: "/deliveries", label: "Livraisons", icon: Truck, roles: ["admin", "directeur", "manager"] },
|
||||||
|
{ path: "/bl-reconciliation", label: "Rapprochement BL / factures", icon: FileCheck, roles: ["admin", "directeur"] },
|
||||||
|
{ path: "/avoirs", label: "Avoirs", icon: Receipt, roles: ["admin", "directeur", "manager"] },
|
||||||
|
{ path: "/payment-schedule", label: "Échéancier", icon: CreditCard, roles: ["admin", "directeur"] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Planning et infos",
|
||||||
|
items: [
|
||||||
|
{ path: "/publicities", label: "Publicités", icon: Megaphone, roles: ALL_ROLES },
|
||||||
|
{ path: "/contacts", label: "Contacts", icon: BookUser, roles: ALL_ROLES },
|
||||||
|
{ path: "/analytics", label: "Statistiques", icon: ChartLine, roles: ["admin", "directeur", "manager"] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Administration",
|
||||||
|
items: [
|
||||||
|
{ path: "/users", label: "Utilisateurs", icon: UserCog, roles: ["admin"] },
|
||||||
|
{ path: "/groups", label: "Magasins", icon: Store, roles: ["admin"] },
|
||||||
|
{ path: "/suppliers", label: "Fiches fournisseurs", icon: Building2, roles: ["admin"] },
|
||||||
|
{ path: "/utilities", label: "Paramètres", icon: Settings, roles: ["admin"] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Sections visibles pour un rôle, sans les sections devenues vides
|
||||||
|
export function getNavSections(role?: string): NavSection[] {
|
||||||
|
if (!role) return [];
|
||||||
|
return NAV_SECTIONS
|
||||||
|
.map((section) => ({
|
||||||
|
...section,
|
||||||
|
items: section.items.filter((item) => item.roles.includes(role as Role)),
|
||||||
|
}))
|
||||||
|
.filter((section) => section.items.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Barre du bas sur téléphone : les 4 premières entrées autorisées de cette liste,
|
||||||
|
// le reste est dans « Menu »
|
||||||
|
const MOBILE_SHORTCUT_PATHS = ["/", "/deliveries", "/tasks", "/dlc", "/customer-orders"];
|
||||||
|
const MOBILE_SHORTCUT_COUNT = 4;
|
||||||
|
|
||||||
|
export function getMobileShortcuts(role?: string): NavItem[] {
|
||||||
|
const allowed = getNavSections(role).flatMap((section) => section.items);
|
||||||
|
return MOBILE_SHORTCUT_PATHS
|
||||||
|
.map((path) => allowed.find((item) => item.path === path))
|
||||||
|
.filter((item): item is NavItem => Boolean(item))
|
||||||
|
.slice(0, MOBILE_SHORTCUT_COUNT);
|
||||||
|
}
|
||||||
|
|
||||||
|
// « / » et « /dashboard » activent tous deux Accueil ; les autres entrées
|
||||||
|
// restent actives sur leurs sous-adresses
|
||||||
|
export function isNavItemActive(itemPath: string, location: string): boolean {
|
||||||
|
if (itemPath === "/") return location === "/" || location === "/dashboard";
|
||||||
|
return location === itemPath || location.startsWith(`${itemPath}/`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNavItemForPath(location: string): NavItem | undefined {
|
||||||
|
for (const section of NAV_SECTIONS) {
|
||||||
|
const item = section.items.find((navItem) => isNavItemActive(navItem.path, location));
|
||||||
|
if (item) return item;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRoleLabel(role?: string): string {
|
||||||
|
switch (role) {
|
||||||
|
case "admin": return "Administrateur";
|
||||||
|
case "directeur": return "Directeur";
|
||||||
|
case "manager": return "Manager";
|
||||||
|
default: return "Employé";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -66,6 +66,26 @@ export const getQueryFn: <T>(options: {
|
|||||||
return await res.json();
|
return await res.json();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Clé de cache de l'utilisateur connecté, partagée par toute l'application
|
||||||
|
export const AUTH_QUERY_KEY = ["/api/user"] as const;
|
||||||
|
|
||||||
|
// Lecture de l'utilisateur connecté. Un 401 signifie simplement « non connecté » :
|
||||||
|
// on renvoie null sans rediriger (sinon boucle sur la page de connexion).
|
||||||
|
export async function fetchCurrentUser(): Promise<any> {
|
||||||
|
const res = await fetch("/api/user", {
|
||||||
|
credentials: "include",
|
||||||
|
cache: "no-cache",
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 401) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await throwIfResNotOk(res);
|
||||||
|
return await res.json();
|
||||||
|
}
|
||||||
|
|
||||||
export const queryClient = new QueryClient({
|
export const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
@@ -77,15 +97,22 @@ export const queryClient = new QueryClient({
|
|||||||
staleTime: 30 * 1000, // 30 secondes de cache pour de meilleures performances
|
staleTime: 30 * 1000, // 30 secondes de cache pour de meilleures performances
|
||||||
gcTime: 10 * 60 * 1000, // 10 minutes
|
gcTime: 10 * 60 * 1000, // 10 minutes
|
||||||
retry: (failureCount, error: any) => {
|
retry: (failureCount, error: any) => {
|
||||||
|
const message: string = error?.message ?? '';
|
||||||
// Ne pas retry les erreurs d'authentification et rediriger
|
// Ne pas retry les erreurs d'authentification et rediriger
|
||||||
if (error?.message?.includes('401') || error?.message?.includes('Unauthorized')) {
|
if (message.includes('401') || message.includes('Unauthorized')) {
|
||||||
if (typeof window !== 'undefined' && window.location.pathname !== '/auth') {
|
if (typeof window !== 'undefined' && window.location.pathname !== '/auth') {
|
||||||
console.log('🔄 Query 401 - Redirecting to auth page');
|
console.log('🔄 Query 401 - Redirecting to auth page');
|
||||||
window.location.href = '/auth';
|
window.location.href = '/auth';
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return failureCount < 2;
|
// Erreur côté client (403 droit refusé, 404...) : un nouvel essai
|
||||||
|
// donnerait le même résultat et retarderait l'affichage de l'erreur
|
||||||
|
if (/^4\d\d\b/.test(message)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Erreur serveur ou réseau : un seul nouvel essai
|
||||||
|
return failureCount < 1;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
mutations: {
|
mutations: {
|
||||||
@@ -93,3 +120,19 @@ export const queryClient = new QueryClient({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Données de référence qui changent rarement (les mutations fournisseurs et
|
||||||
|
// magasins invalident déjà ces clés) : pas de rechargement à chaque ouverture
|
||||||
|
// de page ou de modale.
|
||||||
|
queryClient.setQueryDefaults(["/api/suppliers"], { staleTime: 5 * 60 * 1000 });
|
||||||
|
queryClient.setQueryDefaults(["/api/groups"], { staleTime: 5 * 60 * 1000 });
|
||||||
|
|
||||||
|
// Utilisateur connecté : chargé une seule fois pour toute l'application, puis
|
||||||
|
// rafraîchi explicitement (connexion, refreshAuth). Pas de nouvel essai, ni au
|
||||||
|
// montage d'un composant : un échec équivaut à « non connecté ».
|
||||||
|
queryClient.setQueryDefaults(AUTH_QUERY_KEY, {
|
||||||
|
queryFn: fetchCurrentUser,
|
||||||
|
staleTime: Infinity,
|
||||||
|
retry: false,
|
||||||
|
retryOnMount: false,
|
||||||
|
});
|
||||||
@@ -2,4 +2,21 @@ import { createRoot } from "react-dom/client";
|
|||||||
import App from "./App";
|
import App from "./App";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
||||||
|
// Les pages sont chargées à la demande : après une mise en production, les
|
||||||
|
// anciens fichiers JS n'existent plus sur le serveur et le chargement d'une page
|
||||||
|
// échoue. On recharge alors l'application une seule fois pour récupérer la
|
||||||
|
// nouvelle version (au plus une fois par minute, pour ne pas boucler si le
|
||||||
|
// serveur est indisponible ; l'erreur est alors affichée normalement).
|
||||||
|
window.addEventListener("vite:preloadError", () => {
|
||||||
|
const storageKey = "logiflow-chunk-reload-at";
|
||||||
|
try {
|
||||||
|
const lastReload = Number(sessionStorage.getItem(storageKey) || 0);
|
||||||
|
if (Date.now() - lastReload < 60 * 1000) return;
|
||||||
|
sessionStorage.setItem(storageKey, String(Date.now()));
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(<App />);
|
createRoot(document.getElementById("root")!).render(<App />);
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery, keepPreviousData } from "@tanstack/react-query";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -52,11 +52,13 @@ export default function Analytics() {
|
|||||||
return params.toString();
|
return params.toString();
|
||||||
}, [dateRange, selectedSuppliers, selectedStores, selectedStatus]);
|
}, [dateRange, selectedSuppliers, selectedStores, selectedStatus]);
|
||||||
|
|
||||||
// Récupération des données
|
// Récupération des données. Au changement de filtre (période, fournisseur,
|
||||||
const { data: summary, isLoading: summaryLoading } = useQuery({
|
// magasin, granularité), les résultats précédents restent affichés, atténués
|
||||||
|
// et signalés par « Mise à jour… », au lieu de remplacer toute la page par un
|
||||||
|
// spinner : le chargement plein écran ne concerne que le premier affichage.
|
||||||
|
const { data: summary, isLoading: summaryLoading, isPlaceholderData: summaryUpdating } = useQuery({
|
||||||
queryKey: ['/api/analytics/summary', queryParams],
|
queryKey: ['/api/analytics/summary', queryParams],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
console.log('📊 [ANALYTICS] Fetching summary with params:', queryParams);
|
|
||||||
const response = await fetch(`/api/analytics/summary?${queryParams}`, {
|
const response = await fetch(`/api/analytics/summary?${queryParams}`, {
|
||||||
credentials: 'include'
|
credentials: 'include'
|
||||||
});
|
});
|
||||||
@@ -64,18 +66,16 @@ export default function Analytics() {
|
|||||||
console.error('❌ [ANALYTICS] Summary fetch failed:', response.status, response.statusText);
|
console.error('❌ [ANALYTICS] Summary fetch failed:', response.status, response.statusText);
|
||||||
throw new Error('Failed to fetch summary');
|
throw new Error('Failed to fetch summary');
|
||||||
}
|
}
|
||||||
const data = await response.json();
|
return response.json();
|
||||||
console.log('✅ [ANALYTICS] Summary data received:', data);
|
|
||||||
return data;
|
|
||||||
},
|
},
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
refetchInterval: isRefreshing ? 30000 : false
|
refetchInterval: isRefreshing ? 30000 : false
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: timeseries } = useQuery({
|
const { data: timeseries, isPlaceholderData: timeseriesUpdating } = useQuery({
|
||||||
queryKey: ['/api/analytics/timeseries', queryParams, granularity],
|
queryKey: ['/api/analytics/timeseries', queryParams, granularity],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const url = `/api/analytics/timeseries?${queryParams}&granularity=${granularity}`;
|
const url = `/api/analytics/timeseries?${queryParams}&granularity=${granularity}`;
|
||||||
console.log('📊 [ANALYTICS] Fetching timeseries:', url);
|
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
credentials: 'include'
|
credentials: 'include'
|
||||||
});
|
});
|
||||||
@@ -83,13 +83,12 @@ export default function Analytics() {
|
|||||||
console.error('❌ [ANALYTICS] Timeseries fetch failed:', response.status, response.statusText);
|
console.error('❌ [ANALYTICS] Timeseries fetch failed:', response.status, response.statusText);
|
||||||
throw new Error('Failed to fetch timeseries');
|
throw new Error('Failed to fetch timeseries');
|
||||||
}
|
}
|
||||||
const data = await response.json();
|
return response.json();
|
||||||
console.log('✅ [ANALYTICS] Timeseries data received:', data?.length || 0, 'entries');
|
},
|
||||||
return data;
|
placeholderData: keepPreviousData,
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: bySupplier } = useQuery({
|
const { data: bySupplier, isPlaceholderData: bySupplierUpdating } = useQuery({
|
||||||
queryKey: ['/api/analytics/by-supplier', queryParams],
|
queryKey: ['/api/analytics/by-supplier', queryParams],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const response = await fetch(`/api/analytics/by-supplier?${queryParams}`, {
|
const response = await fetch(`/api/analytics/by-supplier?${queryParams}`, {
|
||||||
@@ -97,10 +96,11 @@ export default function Analytics() {
|
|||||||
});
|
});
|
||||||
if (!response.ok) throw new Error('Failed to fetch by supplier');
|
if (!response.ok) throw new Error('Failed to fetch by supplier');
|
||||||
return response.json();
|
return response.json();
|
||||||
}
|
},
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: byStore } = useQuery({
|
const { data: byStore, isPlaceholderData: byStoreUpdating } = useQuery({
|
||||||
queryKey: ['/api/analytics/by-store', queryParams],
|
queryKey: ['/api/analytics/by-store', queryParams],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const response = await fetch(`/api/analytics/by-store?${queryParams}`, {
|
const response = await fetch(`/api/analytics/by-store?${queryParams}`, {
|
||||||
@@ -108,9 +108,14 @@ export default function Analytics() {
|
|||||||
});
|
});
|
||||||
if (!response.ok) throw new Error('Failed to fetch by store');
|
if (!response.ok) throw new Error('Failed to fetch by store');
|
||||||
return response.json();
|
return response.json();
|
||||||
}
|
},
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isUpdating = summaryUpdating || timeseriesUpdating || bySupplierUpdating || byStoreUpdating;
|
||||||
|
// Bloc dont les chiffres viennent encore des filtres précédents
|
||||||
|
const updatingClass = (updating: boolean) => `transition-opacity ${updating ? 'opacity-60' : ''}`;
|
||||||
|
|
||||||
const { data: suppliers } = useQuery({
|
const { data: suppliers } = useQuery({
|
||||||
queryKey: ['/api/suppliers'],
|
queryKey: ['/api/suppliers'],
|
||||||
});
|
});
|
||||||
@@ -161,6 +166,12 @@ export default function Analytics() {
|
|||||||
<p className="text-gray-600 mt-1">Analysez vos performances logistiques</p>
|
<p className="text-gray-600 mt-1">Analysez vos performances logistiques</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{isUpdating && (
|
||||||
|
<span className="flex items-center text-sm text-gray-500" role="status">
|
||||||
|
<RefreshCw className="h-4 w-4 mr-1 animate-spin" />
|
||||||
|
Mise à jour…
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -330,7 +341,7 @@ export default function Analytics() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* KPIs */}
|
{/* KPIs */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className={`grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 ${updatingClass(summaryUpdating)}`}>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<CardTitle className="text-sm font-medium text-gray-600">
|
<CardTitle className="text-sm font-medium text-gray-600">
|
||||||
@@ -395,7 +406,7 @@ export default function Analytics() {
|
|||||||
{/* Graphiques */}
|
{/* Graphiques */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
{/* Évolution temporelle */}
|
{/* Évolution temporelle */}
|
||||||
<Card>
|
<Card className={updatingClass(timeseriesUpdating)}>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Activity className="h-5 w-5" />
|
<Activity className="h-5 w-5" />
|
||||||
@@ -429,7 +440,7 @@ export default function Analytics() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Répartition par fournisseur */}
|
{/* Répartition par fournisseur */}
|
||||||
<Card>
|
<Card className={updatingClass(bySupplierUpdating)}>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<PieChart className="h-5 w-5" />
|
<PieChart className="h-5 w-5" />
|
||||||
@@ -471,7 +482,7 @@ export default function Analytics() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Performance par magasin */}
|
{/* Performance par magasin */}
|
||||||
<Card>
|
<Card className={updatingClass(byStoreUpdating)}>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Store className="h-5 w-5" />
|
<Store className="h-5 w-5" />
|
||||||
@@ -511,7 +522,7 @@ export default function Analytics() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Top fournisseurs */}
|
{/* Top fournisseurs */}
|
||||||
<Card>
|
<Card className={updatingClass(summaryUpdating)}>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<TrendingUp className="h-5 w-5" />
|
<TrendingUp className="h-5 w-5" />
|
||||||
|
|||||||
@@ -62,13 +62,19 @@ export default function AuthPage() {
|
|||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
toast({
|
toast({
|
||||||
title: "Connexion réussie",
|
title: "Connexion réussie",
|
||||||
description: "Redirection en cours...",
|
description: "Bienvenue dans LogiFlow",
|
||||||
});
|
});
|
||||||
|
|
||||||
// En production, recharger la page pour éviter les problèmes de synchronisation
|
// L'utilisateur est relu une fois et placé dans le cache partagé : toute
|
||||||
setTimeout(() => {
|
// l'application bascule directement sur le tableau de bord, sans attente
|
||||||
|
// ni rechargement complet
|
||||||
|
const refreshedUserData = await forceAuthRefresh();
|
||||||
|
if (refreshedUserData && refreshedUserData.id) {
|
||||||
|
setLocation("/", { replace: true });
|
||||||
|
} else {
|
||||||
|
// Session pas encore visible : rechargement complet comme auparavant
|
||||||
window.location.href = "/";
|
window.location.href = "/";
|
||||||
}, 500);
|
}
|
||||||
} else {
|
} else {
|
||||||
const errorData = await response.json().catch(() => ({}));
|
const errorData = await response.json().catch(() => ({}));
|
||||||
toast({
|
toast({
|
||||||
@@ -138,16 +144,6 @@ export default function AuthPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Debug logging for authentication state
|
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
console.log('🔍 AuthPage Debug:', {
|
|
||||||
isAuthenticated,
|
|
||||||
isLoading,
|
|
||||||
userId: user?.id,
|
|
||||||
username: user?.username
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (defaultCredentialsCheck) {
|
if (defaultCredentialsCheck) {
|
||||||
setShowDefaultCredentials(defaultCredentialsCheck.showDefault);
|
setShowDefaultCredentials(defaultCredentialsCheck.showDefault);
|
||||||
|
|||||||
+264
-204
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useMemo, useRef, useDeferredValue } from "react";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Plus, Search, FileText, CheckCircle, AlertCircle, Clock, Edit, Trash2, UserCheck, Send, Upload, XCircle, MessageSquare, Settings } from "lucide-react";
|
import { Plus, Search, FileText, CheckCircle, AlertCircle, Clock, Edit, Trash2, UserCheck, Send, Upload, XCircle, MessageSquare, Settings } from "lucide-react";
|
||||||
import { useStore } from "@/contexts/StoreContext";
|
import { useStore } from "@/contexts/StoreContext";
|
||||||
@@ -21,6 +21,7 @@ import { format } from "date-fns";
|
|||||||
import { fr } from "date-fns/locale";
|
import { fr } from "date-fns/locale";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { apiRequest } from "@/lib/queryClient";
|
import { apiRequest } from "@/lib/queryClient";
|
||||||
|
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
interface Avoir {
|
interface Avoir {
|
||||||
@@ -108,6 +109,58 @@ type AvoirUpdateData = AvoirFormData & {
|
|||||||
nocodbVerifiedAt?: Date | null;
|
nocodbVerifiedAt?: Date | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Tableau vide stable tant que la liste n'est pas chargée (évite de relancer
|
||||||
|
// effets et calculs mémoïsés à chaque rendu)
|
||||||
|
const NO_AVOIRS: Avoir[] = [];
|
||||||
|
|
||||||
|
// Nombre maximal de vérifications de facture simultanées
|
||||||
|
const MAX_CONCURRENT_VERIFICATIONS = 4;
|
||||||
|
|
||||||
|
// Le montant de l'avoir doit-il être remplacé par celui de la facture ?
|
||||||
|
// Oui s'il est vide ou différent au centime près ; sinon la mise à jour ne
|
||||||
|
// changerait rien et la requête est évitée.
|
||||||
|
function amountDiffers(current: unknown, invoiceAmount: number) {
|
||||||
|
if (current === null || current === undefined || current === '') return true;
|
||||||
|
return Math.round(Number(current) * 100) !== Math.round(Number(invoiceAmount) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtrage par recherche (fournisseur, référence, commentaire)
|
||||||
|
function filterAvoirsBySearch(avoirsList: Avoir[], searchTerm: string) {
|
||||||
|
const searchLower = searchTerm.toLowerCase();
|
||||||
|
return avoirsList.filter((avoir) => (
|
||||||
|
avoir.supplier?.name?.toLowerCase().includes(searchLower) ||
|
||||||
|
avoir.invoiceReference?.toLowerCase().includes(searchLower) ||
|
||||||
|
avoir.comment?.toLowerCase().includes(searchLower)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Durée d'envoi affichée dans le modal d'attente. Le compteur vit dans ce
|
||||||
|
// composant pour ne pas re-rendre toute la page chaque seconde ; il repart de
|
||||||
|
// zéro à chaque ouverture du modal et s'arrête à sa fermeture.
|
||||||
|
function UploadElapsedTime() {
|
||||||
|
const [processingSeconds, setProcessingSeconds] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setProcessingSeconds(prev => prev + 1);
|
||||||
|
}, 1000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p className="text-sm text-gray-600 text-center">
|
||||||
|
Envoi du fichier vers le webhook... {processingSeconds}s
|
||||||
|
</p>
|
||||||
|
{processingSeconds > 30 && (
|
||||||
|
<p className="text-xs text-gray-500 mt-2 text-center">
|
||||||
|
Le traitement peut prendre quelques minutes
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function Avoirs() {
|
export default function Avoirs() {
|
||||||
const [activeTab, setActiveTab] = useState("pending");
|
const [activeTab, setActiveTab] = useState("pending");
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
@@ -122,10 +175,8 @@ export default function Avoirs() {
|
|||||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
|
||||||
// État pour le modal d'attente du webhook
|
// État pour le modal d'attente du webhook (compteur dans UploadElapsedTime)
|
||||||
const [showWaitingModal, setShowWaitingModal] = useState(false);
|
const [showWaitingModal, setShowWaitingModal] = useState(false);
|
||||||
const [processingSeconds, setProcessingSeconds] = useState(0);
|
|
||||||
const [processingTimeout, setProcessingTimeout] = useState<NodeJS.Timeout | null>(null);
|
|
||||||
|
|
||||||
// États pour le système de vérification de facture
|
// États pour le système de vérification de facture
|
||||||
const [avoirVerificationResults, setAvoirVerificationResults] = useState<Record<number, any>>({});
|
const [avoirVerificationResults, setAvoirVerificationResults] = useState<Record<number, any>>({});
|
||||||
@@ -143,10 +194,8 @@ export default function Avoirs() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
|
||||||
// Fetch user profile
|
// Utilisateur connecté : cache partagé par toute l'application (pas de requête supplémentaire)
|
||||||
const { data: user } = useQuery({
|
const { user } = useAuthUnified();
|
||||||
queryKey: ['/api/user'],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fetch groups for store filter
|
// Fetch groups for store filter
|
||||||
const { data: groups = [] } = useQuery<Group[]>({
|
const { data: groups = [] } = useQuery<Group[]>({
|
||||||
@@ -162,86 +211,110 @@ export default function Avoirs() {
|
|||||||
|
|
||||||
// Fetch avoirs avec filtrage par groupe (comme Orders/Deliveries)
|
// Fetch avoirs avec filtrage par groupe (comme Orders/Deliveries)
|
||||||
const avoirsUrl = `/api/avoirs${selectedStoreId ? `?storeId=${selectedStoreId}` : ''}`;
|
const avoirsUrl = `/api/avoirs${selectedStoreId ? `?storeId=${selectedStoreId}` : ''}`;
|
||||||
const { data: avoirs = [], isLoading } = useQuery<Avoir[]>({
|
const { data: avoirs = NO_AVOIRS, isLoading } = useQuery<Avoir[]>({
|
||||||
queryKey: [avoirsUrl, selectedStoreId, (user as any)?.role],
|
queryKey: [avoirsUrl, selectedStoreId, (user as any)?.role],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
console.log('💰 Fetching avoirs from:', avoirsUrl);
|
|
||||||
const response = await fetch(avoirsUrl, { credentials: 'include' });
|
const response = await fetch(avoirsUrl, { credentials: 'include' });
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to fetch avoirs');
|
throw new Error('Failed to fetch avoirs');
|
||||||
}
|
}
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
console.log('💰 Avoirs received:', Array.isArray(data) ? data.length : 'NOT_ARRAY', 'items');
|
|
||||||
|
|
||||||
// 🔍 Debug: Vérifier les champs nocodbVerified
|
|
||||||
if (Array.isArray(data) && data.length > 0) {
|
|
||||||
console.log('🔍 Premiers avoirs avec nocodbVerified:', data.map(a => ({
|
|
||||||
id: a.id,
|
|
||||||
invoiceReference: a.invoiceReference,
|
|
||||||
nocodbVerified: a.nocodbVerified,
|
|
||||||
nocodbVerifiedAt: a.nocodbVerifiedAt
|
|
||||||
})));
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.isArray(data) ? data : [];
|
return Array.isArray(data) ? data : [];
|
||||||
},
|
},
|
||||||
enabled: !!user,
|
enabled: !!user,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 🔄 Charger les vérifications depuis le cache serveur au démarrage
|
// Recharge la liste des avoirs, quelle que soit la variante de clé
|
||||||
|
// (avec ou sans magasin sélectionné, bureau ou mobile)
|
||||||
|
const invalidateAvoirs = () => queryClient.invalidateQueries({
|
||||||
|
predicate: (query) => {
|
||||||
|
return query.queryKey[0]?.toString().includes('/api/avoirs') || false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Données du PUT qui reporte le montant de la facture sur l'avoir : le serveur
|
||||||
|
// remet à null les champs absents, on renvoie donc les valeurs actuelles
|
||||||
|
const buildAmountUpdate = (avoir: Avoir, amount: number): AvoirUpdateData => ({
|
||||||
|
supplierId: avoir.supplierId,
|
||||||
|
groupId: avoir.groupId,
|
||||||
|
invoiceReference: avoir.invoiceReference || "",
|
||||||
|
amount,
|
||||||
|
comment: avoir.comment || "",
|
||||||
|
commercialProcessed: avoir.commercialProcessed || false,
|
||||||
|
status: avoir.status as "En attente de demande" | "Demandé" | "Reçu",
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// File d'attente des vérifications de facture : au plus
|
||||||
|
// MAX_CONCURRENT_VERIFICATIONS requêtes simultanées, au lieu d'une boucle
|
||||||
|
// séquentielle au chargement ou d'une rafale de requêtes (« Vérifier toutes »).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const verificationQueueRef = useRef<Array<() => Promise<void>>>([]);
|
||||||
|
const activeVerificationsRef = useRef(0);
|
||||||
|
// Référence déjà vérifiée (ou en cours) pour chaque avoir : un rechargement de
|
||||||
|
// la liste ne relance pas les vérifications déjà faites
|
||||||
|
const verifiedReferencesRef = useRef<Map<number, string>>(new Map());
|
||||||
|
// Dernière liste reçue, relue au moment de remplir un montant
|
||||||
|
const avoirsRef = useRef<Avoir[]>(avoirs);
|
||||||
|
avoirsRef.current = avoirs;
|
||||||
|
|
||||||
|
const drainVerificationQueue = () => {
|
||||||
|
while (
|
||||||
|
activeVerificationsRef.current < MAX_CONCURRENT_VERIFICATIONS &&
|
||||||
|
verificationQueueRef.current.length > 0
|
||||||
|
) {
|
||||||
|
const task = verificationQueueRef.current.shift();
|
||||||
|
if (!task) break;
|
||||||
|
|
||||||
|
activeVerificationsRef.current += 1;
|
||||||
|
task().finally(() => {
|
||||||
|
activeVerificationsRef.current -= 1;
|
||||||
|
drainVerificationQueue();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Met les tâches en file ; la promesse est résolue quand toutes sont terminées
|
||||||
|
// (chaque tâche traite elle-même ses erreurs)
|
||||||
|
const runVerificationTasks = (tasks: Array<() => Promise<void>>) =>
|
||||||
|
Promise.all(tasks.map(task => new Promise<void>(resolve => {
|
||||||
|
verificationQueueRef.current.push(() => task().catch(() => undefined).finally(resolve));
|
||||||
|
drainVerificationQueue();
|
||||||
|
})));
|
||||||
|
|
||||||
|
// 🔄 Charger les vérifications depuis le cache serveur à l'affichage de la liste.
|
||||||
|
// Seuls les avoirs non validés dont la référence n'a pas encore été vérifiée
|
||||||
|
// sont envoyés (un avoir validé affiche « Validé » quel que soit le résultat) ;
|
||||||
|
// les résultats sont appliqués en une seule fois, comme auparavant.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadCachedVerifications = async () => {
|
const avoirsToVerify = avoirs.filter(avoir =>
|
||||||
console.log('🔍 LoadCache - Début vérification:', {
|
avoir.invoiceReference?.trim() &&
|
||||||
hasAvoirs: !!avoirs,
|
!avoir.nocodbVerified &&
|
||||||
avoirCount: avoirs?.length || 0
|
verifiedReferencesRef.current.get(avoir.id) !== avoir.invoiceReference
|
||||||
});
|
);
|
||||||
|
if (avoirsToVerify.length === 0) return;
|
||||||
|
|
||||||
if (!avoirs || avoirs.length === 0) return;
|
// Marquage immédiat : un rechargement pendant la vérification ne la double pas
|
||||||
|
avoirsToVerify.forEach(avoir => verifiedReferencesRef.current.set(avoir.id, avoir.invoiceReference || ''));
|
||||||
|
|
||||||
const cachedResults: Record<number, any> = {};
|
const cachedResults: Record<number, any> = {};
|
||||||
|
runVerificationTasks(avoirsToVerify.map(avoir => async () => {
|
||||||
// Charger les vérifications depuis le cache serveur pour TOUS les avoirs avec référence
|
try {
|
||||||
for (const avoir of avoirs) {
|
const result = await apiRequest(`/api/avoirs/${avoir.id}/verify-invoice`, 'POST', {
|
||||||
console.log('🔍 LoadCache - Avoir analysé:', {
|
|
||||||
id: avoir.id,
|
|
||||||
hasInvoiceRef: !!avoir.invoiceReference?.trim(),
|
|
||||||
invoiceReference: avoir.invoiceReference,
|
invoiceReference: avoir.invoiceReference,
|
||||||
nocodbVerified: avoir.nocodbVerified
|
forceRefresh: false // Utiliser le cache si disponible
|
||||||
});
|
});
|
||||||
|
if (result?.exists !== undefined) {
|
||||||
// Si l'avoir a une référence de facture, vérifier le cache serveur
|
cachedResults[avoir.id] = result;
|
||||||
if (avoir.invoiceReference?.trim()) {
|
|
||||||
try {
|
|
||||||
const result = await apiRequest(`/api/avoirs/${avoir.id}/verify-invoice`, 'POST', {
|
|
||||||
invoiceReference: avoir.invoiceReference,
|
|
||||||
forceRefresh: false // Utiliser le cache si disponible
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('🔍 LoadCache - Résultat cache serveur pour avoir', avoir.id, ':', result);
|
|
||||||
|
|
||||||
if (result.exists !== undefined) {
|
|
||||||
cachedResults[avoir.id] = result;
|
|
||||||
console.log('✅ LoadCache - Avoir ajouté au cache depuis serveur:', avoir.id);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.log('⚠️ LoadCache - Erreur cache serveur pour avoir', avoir.id, ':', error);
|
|
||||||
// Continuer sans erreur si le cache échoue
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
// Vérification silencieuse : la loupe reste disponible pour réessayer
|
||||||
}
|
}
|
||||||
|
})).then(() => {
|
||||||
console.log('🔍 LoadCache - Résultats finaux:', {
|
|
||||||
cachedCount: Object.keys(cachedResults).length,
|
|
||||||
cachedResults
|
|
||||||
});
|
|
||||||
|
|
||||||
if (Object.keys(cachedResults).length > 0) {
|
if (Object.keys(cachedResults).length > 0) {
|
||||||
setAvoirVerificationResults(prev => ({ ...prev, ...cachedResults }));
|
setAvoirVerificationResults(prev => ({ ...prev, ...cachedResults }));
|
||||||
console.log('✅ Vérifications chargées depuis le serveur:', cachedResults);
|
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
|
|
||||||
loadCachedVerifications();
|
|
||||||
}, [avoirs]);
|
}, [avoirs]);
|
||||||
|
|
||||||
// Create avoir mutation
|
// Create avoir mutation
|
||||||
@@ -262,11 +335,7 @@ export default function Avoirs() {
|
|||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
// ✅ FIX: Invalider toutes les variations de queryKey avoirs
|
// ✅ FIX: Invalider toutes les variations de queryKey avoirs
|
||||||
queryClient.invalidateQueries({
|
invalidateAvoirs();
|
||||||
predicate: (query) => {
|
|
||||||
return query.queryKey[0]?.toString().includes('/api/avoirs') || false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
setIsCreateDialogOpen(false);
|
setIsCreateDialogOpen(false);
|
||||||
toast({
|
toast({
|
||||||
title: "Avoir créé",
|
title: "Avoir créé",
|
||||||
@@ -300,11 +369,7 @@ export default function Avoirs() {
|
|||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
// ✅ FIX: Invalider toutes les variations de queryKey avoirs
|
// ✅ FIX: Invalider toutes les variations de queryKey avoirs
|
||||||
queryClient.invalidateQueries({
|
invalidateAvoirs();
|
||||||
predicate: (query) => {
|
|
||||||
return query.queryKey[0]?.toString().includes('/api/avoirs') || false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
setIsEditDialogOpen(false);
|
setIsEditDialogOpen(false);
|
||||||
setSelectedAvoir(null);
|
setSelectedAvoir(null);
|
||||||
toast({
|
toast({
|
||||||
@@ -335,11 +400,7 @@ export default function Avoirs() {
|
|||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
// ✅ FIX: Invalider toutes les variations de queryKey avoirs
|
// ✅ FIX: Invalider toutes les variations de queryKey avoirs
|
||||||
queryClient.invalidateQueries({
|
invalidateAvoirs();
|
||||||
predicate: (query) => {
|
|
||||||
return query.queryKey[0]?.toString().includes('/api/avoirs') || false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
setIsDeleteDialogOpen(false);
|
setIsDeleteDialogOpen(false);
|
||||||
setSelectedAvoir(null);
|
setSelectedAvoir(null);
|
||||||
toast({
|
toast({
|
||||||
@@ -411,13 +472,6 @@ export default function Avoirs() {
|
|||||||
amount: finalAmount
|
amount: finalAmount
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log('💰 Editing avoir - Form data sent:', {
|
|
||||||
originalCommercial: selectedAvoir.commercialProcessed,
|
|
||||||
formCommercial: finalData.commercialProcessed,
|
|
||||||
editAmountValue,
|
|
||||||
finalAmount,
|
|
||||||
fullData: finalData
|
|
||||||
});
|
|
||||||
editAvoirMutation.mutate({ id: selectedAvoir.id, data: finalData });
|
editAvoirMutation.mutate({ id: selectedAvoir.id, data: finalData });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -498,34 +552,22 @@ export default function Avoirs() {
|
|||||||
// Handle validation/devalidation
|
// Handle validation/devalidation
|
||||||
const handleValidateAvoir = async (avoirId: number) => {
|
const handleValidateAvoir = async (avoirId: number) => {
|
||||||
try {
|
try {
|
||||||
// Utiliser la route spécifique pour la vérification NocoDB
|
// Utiliser la route spécifique pour la vérification NocoDB. Elle marque
|
||||||
|
// aussi le cache de vérification comme réconcilié (permanent) : un appel
|
||||||
|
// séparé à /api/cache/mark-reconciled serait redondant.
|
||||||
await apiRequest(`/api/avoirs/${avoirId}/nocodb-verification`, 'PUT', {
|
await apiRequest(`/api/avoirs/${avoirId}/nocodb-verification`, 'PUT', {
|
||||||
verified: true
|
verified: true
|
||||||
});
|
});
|
||||||
|
|
||||||
// Marquer le cache comme réconcilié (permanent)
|
|
||||||
const avoir = avoirs.find(a => a.id === avoirId);
|
|
||||||
if (avoir?.invoiceReference) {
|
|
||||||
try {
|
|
||||||
await apiRequest('/api/cache/mark-reconciled', 'POST', {
|
|
||||||
invoiceReference: avoir.invoiceReference,
|
|
||||||
groupId: avoir.groupId
|
|
||||||
});
|
|
||||||
} catch (cacheError) {
|
|
||||||
console.warn('Cache marking failed but verification succeeded:', cacheError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ✅ Mettre à jour l'état local immédiatement pour affichage instantané
|
// ✅ Mettre à jour l'état local immédiatement pour affichage instantané
|
||||||
setAvoirVerificationResults(prev => ({
|
setAvoirVerificationResults(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
[avoirId]: { exists: true, fromCache: true, permanent: true, validated: true }
|
[avoirId]: { exists: true, fromCache: true, permanent: true, validated: true }
|
||||||
}));
|
}));
|
||||||
|
|
||||||
console.log('✅ Avoir validé - État local mis à jour:', avoirId);
|
// Recharger les avoirs pour voir les changements en base (toutes les
|
||||||
|
// variantes de clé : la clé contient l'URL avec le magasin sélectionné)
|
||||||
// Recharger les avoirs pour voir les changements en base
|
invalidateAvoirs();
|
||||||
queryClient.invalidateQueries({ queryKey: ['/api/avoirs'] });
|
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: "Avoir validé",
|
title: "Avoir validé",
|
||||||
@@ -553,11 +595,12 @@ export default function Avoirs() {
|
|||||||
delete updated[avoirId]; // Supprimer complètement l'entrée
|
delete updated[avoirId]; // Supprimer complètement l'entrée
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
|
// L'avoir sera revérifié au rechargement de la liste
|
||||||
|
verifiedReferencesRef.current.delete(avoirId);
|
||||||
|
|
||||||
console.log('✅ Avoir dévalidé - État local mis à jour:', avoirId);
|
// Recharger les avoirs pour voir les changements en base (toutes les
|
||||||
|
// variantes de clé : la clé contient l'URL avec le magasin sélectionné)
|
||||||
// Recharger les avoirs pour voir les changements en base
|
invalidateAvoirs();
|
||||||
queryClient.invalidateQueries({ queryKey: ['/api/avoirs'] });
|
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: "Avoir dévalidé",
|
title: "Avoir dévalidé",
|
||||||
@@ -593,26 +636,14 @@ export default function Avoirs() {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// Auto-remplissage sécurisé si facture trouvée et montant disponible
|
// Auto-remplissage sécurisé si facture trouvée et montant disponible
|
||||||
|
// (seulement si le montant change : sinon la mise à jour serait inutile)
|
||||||
if (result?.exists === true && result?.invoiceAmount !== undefined && result?.invoiceAmount !== null) {
|
if (result?.exists === true && result?.invoiceAmount !== undefined && result?.invoiceAmount !== null) {
|
||||||
const avoir = avoirs?.find(a => a?.id === variables.avoirId);
|
const avoir = avoirs?.find(a => a?.id === variables.avoirId);
|
||||||
if (avoir && avoir.supplierId && avoir.groupId) {
|
if (avoir && avoir.supplierId && avoir.groupId && amountDiffers(avoir.amount, result.invoiceAmount)) {
|
||||||
try {
|
editAvoirMutation.mutate({
|
||||||
console.log('🔄 Auto-remplissage montant:', { avoirId: variables.avoirId, amount: result.invoiceAmount });
|
id: variables.avoirId,
|
||||||
editAvoirMutation.mutate({
|
data: buildAmountUpdate(avoir, result.invoiceAmount),
|
||||||
id: variables.avoirId,
|
});
|
||||||
data: {
|
|
||||||
supplierId: avoir.supplierId,
|
|
||||||
groupId: avoir.groupId,
|
|
||||||
invoiceReference: avoir.invoiceReference || "",
|
|
||||||
amount: result.invoiceAmount,
|
|
||||||
comment: avoir.comment || "",
|
|
||||||
commercialProcessed: avoir.commercialProcessed || false,
|
|
||||||
status: avoir.status as "En attente de demande" | "Demandé" | "Reçu",
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (autoFillError) {
|
|
||||||
console.error('❌ Erreur auto-remplissage:', autoFillError);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -669,13 +700,8 @@ export default function Avoirs() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('🔍 Déclenchement vérification avoir:', {
|
// Référence vérifiée : le rechargement de la liste ne la revérifiera pas
|
||||||
avoirId: avoir.id,
|
verifiedReferencesRef.current.set(avoir.id, avoir.invoiceReference || '');
|
||||||
invoiceReference: avoir.invoiceReference,
|
|
||||||
supplier: avoir.supplier?.name,
|
|
||||||
group: avoir.group?.name
|
|
||||||
});
|
|
||||||
|
|
||||||
setVerifyingAvoirs(prev => new Set(prev).add(avoir.id));
|
setVerifyingAvoirs(prev => new Set(prev).add(avoir.id));
|
||||||
|
|
||||||
verifyAvoirInvoiceMutation.mutate({
|
verifyAvoirInvoiceMutation.mutate({
|
||||||
@@ -685,8 +711,11 @@ export default function Avoirs() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fonction pour vérifier tous les avoirs avec une référence facture
|
// Fonction pour vérifier tous les avoirs avec une référence facture.
|
||||||
const handleVerifyAllAvoirInvoices = () => {
|
// File d'attente bornée, montants remplis sans recharger la liste à chaque
|
||||||
|
// avoir (ni toast, ni fermeture de modale), puis un seul rafraîchissement et
|
||||||
|
// un seul toast récapitulatif à la fin.
|
||||||
|
const handleVerifyAllAvoirInvoices = async () => {
|
||||||
const avoirsToVerify = avoirs.filter(avoir =>
|
const avoirsToVerify = avoirs.filter(avoir =>
|
||||||
avoir.invoiceReference?.trim() &&
|
avoir.invoiceReference?.trim() &&
|
||||||
(avoir.group?.nocodbTableName || avoir.group?.nocodbConfigId)
|
(avoir.group?.nocodbTableName || avoir.group?.nocodbConfigId)
|
||||||
@@ -700,21 +729,84 @@ export default function Avoirs() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
avoirsToVerify.forEach((avoir, index) => {
|
|
||||||
// Délai échelonné pour éviter la surcharge
|
|
||||||
setTimeout(() => {
|
|
||||||
handleVerifyAvoirInvoice(avoir, true); // Force refresh pour tous
|
|
||||||
}, index * 200); // 200ms entre chaque vérification
|
|
||||||
});
|
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: "Vérification lancée",
|
title: "Vérification lancée",
|
||||||
description: `Vérification de ${avoirsToVerify.length} avoir(s) en cours...`,
|
description: `Vérification de ${avoirsToVerify.length} avoir(s) en cours...`,
|
||||||
});
|
});
|
||||||
};
|
|
||||||
|
|
||||||
// ✅ SUPPRIMÉ : Le chargement automatique causait des crashes JavaScript
|
setVerifyingAvoirs(prev => {
|
||||||
// Les vérifications se font maintenant uniquement à la demande utilisateur
|
const next = new Set(prev);
|
||||||
|
avoirsToVerify.forEach(avoir => next.add(avoir.id));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
avoirsToVerify.forEach(avoir => verifiedReferencesRef.current.set(avoir.id, avoir.invoiceReference || ''));
|
||||||
|
|
||||||
|
let found = 0;
|
||||||
|
let notFound = 0;
|
||||||
|
let failed = 0;
|
||||||
|
let amountsUpdated = 0;
|
||||||
|
|
||||||
|
await runVerificationTasks(avoirsToVerify.map(avoir => async () => {
|
||||||
|
let result: any;
|
||||||
|
let requestFailed = false;
|
||||||
|
try {
|
||||||
|
result = await apiRequest(`/api/avoirs/${avoir.id}/verify-invoice`, 'POST', {
|
||||||
|
invoiceReference: avoir.invoiceReference,
|
||||||
|
forceRefresh: true // Force refresh pour tous
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur vérification facture avoir:', error);
|
||||||
|
requestFailed = true;
|
||||||
|
result = {
|
||||||
|
exists: false,
|
||||||
|
matchType: 'none',
|
||||||
|
errorMessage: error instanceof Error ? error.message : 'Erreur inconnue'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
setAvoirVerificationResults(prev => ({ ...prev, [avoir.id]: result }));
|
||||||
|
setVerifyingAvoirs(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(avoir.id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (requestFailed) {
|
||||||
|
failed += 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (result?.exists !== true) {
|
||||||
|
notFound += 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
found += 1;
|
||||||
|
|
||||||
|
// Report du montant de la facture, à partir des données les plus récentes de l'avoir
|
||||||
|
if (result.invoiceAmount === undefined || result.invoiceAmount === null) return;
|
||||||
|
const current = avoirsRef.current.find(a => a?.id === avoir.id) ?? avoir;
|
||||||
|
if (!current.supplierId || !current.groupId || !amountDiffers(current.amount, result.invoiceAmount)) return;
|
||||||
|
try {
|
||||||
|
await apiRequest(`/api/avoirs/${avoir.id}`, 'PUT', buildAmountUpdate(current, result.invoiceAmount));
|
||||||
|
amountsUpdated += 1;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Erreur auto-remplissage:', error);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Un seul rechargement de la liste, seulement si des montants ont changé
|
||||||
|
if (amountsUpdated > 0) {
|
||||||
|
invalidateAvoirs();
|
||||||
|
}
|
||||||
|
|
||||||
|
const details = [`${found} facture(s) trouvée(s)`, `${notFound} introuvable(s)`];
|
||||||
|
if (failed > 0) details.push(`${failed} erreur(s)`);
|
||||||
|
if (amountsUpdated > 0) details.push(`${amountsUpdated} montant(s) mis à jour`);
|
||||||
|
toast({
|
||||||
|
title: "Vérification terminée",
|
||||||
|
description: details.join(', '),
|
||||||
|
variant: failed > 0 ? "destructive" : "default",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// 🔥 FONCTIONS WEBHOOK MODAL (comme rapprochement)
|
// 🔥 FONCTIONS WEBHOOK MODAL (comme rapprochement)
|
||||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
@@ -743,22 +835,9 @@ export default function Avoirs() {
|
|||||||
setIsUploading(false);
|
setIsUploading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const startProcessingTimer = () => {
|
|
||||||
setProcessingSeconds(0);
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
setProcessingSeconds(prev => prev + 1);
|
|
||||||
}, 1000);
|
|
||||||
setProcessingTimeout(interval);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCloseWaitingModal = () => {
|
const handleCloseWaitingModal = () => {
|
||||||
setShowWaitingModal(false);
|
setShowWaitingModal(false);
|
||||||
setIsUploading(false);
|
setIsUploading(false);
|
||||||
setProcessingSeconds(0);
|
|
||||||
if (processingTimeout) {
|
|
||||||
clearInterval(processingTimeout);
|
|
||||||
setProcessingTimeout(null);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSendAvoir = async () => {
|
const handleSendAvoir = async () => {
|
||||||
@@ -789,7 +868,6 @@ export default function Avoirs() {
|
|||||||
setShowAvoirModal(false);
|
setShowAvoirModal(false);
|
||||||
setShowWaitingModal(true);
|
setShowWaitingModal(true);
|
||||||
setIsUploading(true);
|
setIsUploading(true);
|
||||||
startProcessingTimer();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
@@ -833,11 +911,7 @@ export default function Avoirs() {
|
|||||||
setSelectedFile(null);
|
setSelectedFile(null);
|
||||||
|
|
||||||
// Invalider les caches pour recharger
|
// Invalider les caches pour recharger
|
||||||
queryClient.invalidateQueries({
|
invalidateAvoirs();
|
||||||
predicate: (query) => {
|
|
||||||
return query.queryKey[0]?.toString().includes('/api/avoirs') || false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
handleCloseWaitingModal();
|
handleCloseWaitingModal();
|
||||||
@@ -867,17 +941,6 @@ export default function Avoirs() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Filter avoirs based on search term
|
|
||||||
const filteredAvoirs = avoirs.filter(avoir =>
|
|
||||||
avoir.invoiceReference?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
||||||
avoir.supplier?.name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
||||||
avoir.comment?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
||||||
(avoir.creator.firstName && avoir.creator.lastName
|
|
||||||
? `${avoir.creator.firstName} ${avoir.creator.lastName}`.toLowerCase().includes(searchTerm.toLowerCase())
|
|
||||||
: avoir.creator.username.toLowerCase().includes(searchTerm.toLowerCase())
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Status badge variant
|
// Status badge variant
|
||||||
const getStatusVariant = (status: string) => {
|
const getStatusVariant = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
@@ -906,24 +969,27 @@ export default function Avoirs() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Séparer les avoirs par statut : Finalisé = Reçu ET validé
|
// Séparer les avoirs par statut : Finalisé = Reçu ET validé (recalculé
|
||||||
const pendingAvoirs = avoirs.filter(avoir => !(avoir.status === 'Reçu' && avoir.nocodbVerified));
|
// seulement quand la liste change, pas à chaque frappe ou résultat de vérification)
|
||||||
const completedAvoirs = avoirs.filter(avoir => avoir.status === 'Reçu' && avoir.nocodbVerified);
|
const pendingAvoirs = useMemo(
|
||||||
|
() => avoirs.filter(avoir => !(avoir.status === 'Reçu' && avoir.nocodbVerified)),
|
||||||
|
[avoirs]
|
||||||
|
);
|
||||||
|
const completedAvoirs = useMemo(
|
||||||
|
() => avoirs.filter(avoir => avoir.status === 'Reçu' && avoir.nocodbVerified),
|
||||||
|
[avoirs]
|
||||||
|
);
|
||||||
|
|
||||||
// Filtrage par recherche
|
// Filtrage par recherche, différée pour garder la saisie fluide
|
||||||
const filterAvoirs = (avoirsList: Avoir[]) => {
|
const deferredSearchTerm = useDeferredValue(searchTerm);
|
||||||
return avoirsList.filter((avoir) => {
|
const filteredPendingAvoirs = useMemo(
|
||||||
const searchLower = searchTerm.toLowerCase();
|
() => filterAvoirsBySearch(pendingAvoirs, deferredSearchTerm),
|
||||||
return (
|
[pendingAvoirs, deferredSearchTerm]
|
||||||
avoir.supplier?.name?.toLowerCase().includes(searchLower) ||
|
);
|
||||||
avoir.invoiceReference?.toLowerCase().includes(searchLower) ||
|
const filteredCompletedAvoirs = useMemo(
|
||||||
avoir.comment?.toLowerCase().includes(searchLower)
|
() => filterAvoirsBySearch(completedAvoirs, deferredSearchTerm),
|
||||||
);
|
[completedAvoirs, deferredSearchTerm]
|
||||||
});
|
);
|
||||||
};
|
|
||||||
|
|
||||||
const filteredPendingAvoirs = filterAvoirs(pendingAvoirs);
|
|
||||||
const filteredCompletedAvoirs = filterAvoirs(completedAvoirs);
|
|
||||||
|
|
||||||
const canEditDelete = ['admin', 'directeur'].includes((user as any)?.role);
|
const canEditDelete = ['admin', 'directeur'].includes((user as any)?.role);
|
||||||
|
|
||||||
@@ -1243,7 +1309,8 @@ export default function Avoirs() {
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
{renderAvoirTable(filteredPendingAvoirs)}
|
{/* Seul le tableau de l'onglet affiché est construit */}
|
||||||
|
{activeTab === "pending" && renderAvoirTable(filteredPendingAvoirs)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -1260,7 +1327,7 @@ export default function Avoirs() {
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
{renderAvoirTable(filteredCompletedAvoirs)}
|
{activeTab === "completed" && renderAvoirTable(filteredCompletedAvoirs)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -1691,14 +1758,7 @@ export default function Avoirs() {
|
|||||||
|
|
||||||
<div className="flex flex-col items-center p-6">
|
<div className="flex flex-col items-center p-6">
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mb-4"></div>
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mb-4"></div>
|
||||||
<p className="text-sm text-gray-600 text-center">
|
<UploadElapsedTime />
|
||||||
Envoi du fichier vers le webhook... {processingSeconds}s
|
|
||||||
</p>
|
|
||||||
{processingSeconds > 30 && (
|
|
||||||
<p className="text-xs text-gray-500 mt-2 text-center">
|
|
||||||
Le traitement peut prendre quelques minutes
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect, useRef } from "react";
|
import React, { useState, useEffect, useRef, useMemo, useDeferredValue } from "react";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { safeFormat } from "@/lib/dateUtils";
|
import { safeFormat } from "@/lib/dateUtils";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -12,13 +12,90 @@ import { useStore } from "@/contexts/StoreContext";
|
|||||||
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
||||||
import { usePermissions } from "@shared/permissions";
|
import { usePermissions } from "@shared/permissions";
|
||||||
import { Pagination, usePagination } from "@/components/ui/pagination";
|
import { Pagination, usePagination } from "@/components/ui/pagination";
|
||||||
import { Search, Edit, FileText, Settings, Eye, AlertTriangle, X, Check, Trash2, Ban, Filter, Upload, CheckCircle, XCircle, Clock, MessageSquare, Mail } from "lucide-react";
|
import { Search, Edit, FileText, Settings, Eye, AlertTriangle, X, Check, Trash2, Ban, Filter, Upload, CheckCircle, XCircle, Clock, MessageSquare, Mail, FileUp } from "lucide-react";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import ReconciliationComments from "@/components/ReconciliationComments";
|
import ReconciliationComments from "@/components/ReconciliationComments";
|
||||||
import ReconciliationModal from "@/components/modals/ReconciliationModal";
|
import ReconciliationModal from "@/components/modals/ReconciliationModal";
|
||||||
|
import BapUploadModal from "@/components/modals/BapUploadModal";
|
||||||
|
|
||||||
|
// Tableau vide stable tant que la liste n'est pas chargée (évite de relancer
|
||||||
|
// effets et calculs mémoïsés à chaque rendu)
|
||||||
|
const NO_DELIVERIES: any[] = [];
|
||||||
|
|
||||||
|
// Clé de la liste du rapprochement : elle commence par '/api/deliveries', donc
|
||||||
|
// toute invalidation des livraisons (ici ou depuis une autre page) l'atteint
|
||||||
|
const RECONCILIATION_QUERY_KEY = ['/api/deliveries', 'reconciliation'] as const;
|
||||||
|
|
||||||
|
// Filtrage des livraisons par recherche uniquement
|
||||||
|
function filterDeliveriesBySearch(deliveries: any[], searchTerm: string) {
|
||||||
|
if (!searchTerm) return deliveries;
|
||||||
|
|
||||||
|
const searchLower = searchTerm.toLowerCase();
|
||||||
|
return deliveries.filter((delivery: any) => {
|
||||||
|
return (
|
||||||
|
delivery.supplier?.name?.toLowerCase().includes(searchLower) ||
|
||||||
|
delivery.blNumber?.toLowerCase().includes(searchLower) ||
|
||||||
|
delivery.invoiceReference?.toLowerCase().includes(searchLower) ||
|
||||||
|
delivery.group?.name?.toLowerCase().includes(searchLower)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Progression affichée dans le modal d'attente (60 s max). Le compteur vit dans
|
||||||
|
// ce composant pour ne pas re-rendre toute la page chaque seconde ; il repart de
|
||||||
|
// zéro à chaque ouverture du modal et s'arrête à sa fermeture.
|
||||||
|
function WaitingProgress() {
|
||||||
|
const [processingSeconds, setProcessingSeconds] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setProcessingSeconds(prev => {
|
||||||
|
if (prev >= 60) {
|
||||||
|
clearInterval(interval);
|
||||||
|
return 60;
|
||||||
|
}
|
||||||
|
return prev + 1;
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-16 h-16 mx-auto mb-4 relative">
|
||||||
|
<div className="w-16 h-16 border-4 border-gray-200 border-t-blue-600 rounded-full animate-spin"></div>
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<span className="text-xs font-bold text-blue-600">{processingSeconds}s</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="font-medium text-gray-900">
|
||||||
|
Traitement de votre facture en cours...
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
Le workflow peut prendre jusqu'à 1 minute.
|
||||||
|
<br />
|
||||||
|
Veuillez patienter.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 bg-gray-100 rounded-full h-2 w-full">
|
||||||
|
<div
|
||||||
|
className="bg-blue-600 h-2 rounded-full transition-all duration-1000 ease-out"
|
||||||
|
style={{ width: `${Math.min((processingSeconds / 60) * 100, 100)}%` }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 text-xs text-gray-500">
|
||||||
|
{processingSeconds < 60 ? `${60 - processingSeconds}s restantes (max)` : 'Finalisation...'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function BLReconciliation() {
|
export default function BLReconciliation() {
|
||||||
const { user } = useAuthUnified();
|
const { user } = useAuthUnified();
|
||||||
@@ -52,6 +129,7 @@ export default function BLReconciliation() {
|
|||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
const [selectedDelivery, setSelectedDelivery] = useState<any>(null);
|
const [selectedDelivery, setSelectedDelivery] = useState<any>(null);
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [showBapModal, setShowBapModal] = useState(false);
|
||||||
|
|
||||||
// État pour le modal d'envoi de facture
|
// État pour le modal d'envoi de facture
|
||||||
const [showInvoiceModal, setShowInvoiceModal] = useState(false);
|
const [showInvoiceModal, setShowInvoiceModal] = useState(false);
|
||||||
@@ -59,10 +137,8 @@ export default function BLReconciliation() {
|
|||||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
|
||||||
// État pour le modal d'attente du webhook
|
// État pour le modal d'attente du webhook (compteur dans WaitingProgress)
|
||||||
const [showWaitingModal, setShowWaitingModal] = useState(false);
|
const [showWaitingModal, setShowWaitingModal] = useState(false);
|
||||||
const [processingSeconds, setProcessingSeconds] = useState(0);
|
|
||||||
const [processingTimeout, setProcessingTimeout] = useState<NodeJS.Timeout | null>(null);
|
|
||||||
|
|
||||||
// État pour le système de vérification de facture
|
// État pour le système de vérification de facture
|
||||||
const [verificationResults, setVerificationResults] = useState<Record<number, any>>({});
|
const [verificationResults, setVerificationResults] = useState<Record<number, any>>({});
|
||||||
@@ -74,11 +150,6 @@ export default function BLReconciliation() {
|
|||||||
const [showCommentModal, setShowCommentModal] = useState(false);
|
const [showCommentModal, setShowCommentModal] = useState(false);
|
||||||
const [selectedDeliveryForComment, setSelectedDeliveryForComment] = useState<any>(null);
|
const [selectedDeliveryForComment, setSelectedDeliveryForComment] = useState<any>(null);
|
||||||
|
|
||||||
// Récupérer les fournisseurs pour la logique automatique
|
|
||||||
const { data: suppliers = [] } = useQuery<any[]>({
|
|
||||||
queryKey: ['/api/suppliers'],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Historique des relances fournisseurs : dernier envoi affiché sur l'icône mail
|
// Historique des relances fournisseurs : dernier envoi affiché sur l'icône mail
|
||||||
const { data: supplierMailLogs = [] } = useQuery<any[]>({
|
const { data: supplierMailLogs = [] } = useQuery<any[]>({
|
||||||
queryKey: ['/api/supplier-mail-logs', selectedStoreId],
|
queryKey: ['/api/supplier-mail-logs', selectedStoreId],
|
||||||
@@ -125,6 +196,15 @@ export default function BLReconciliation() {
|
|||||||
// Invalidation de cache différée : une seule fois quand la file est vidée
|
// Invalidation de cache différée : une seule fois quand la file est vidée
|
||||||
const needsCacheInvalidationRef = useRef(false);
|
const needsCacheInvalidationRef = useRef(false);
|
||||||
|
|
||||||
|
// Après une modification de livraison : la liste du rapprochement et les
|
||||||
|
// autres listes de livraisons sont invalidées par préfixe (seules les requêtes
|
||||||
|
// affichées sont rechargées) ; l'échéancier, calculé à partir des mêmes
|
||||||
|
// livraisons, est seulement marqué périmé.
|
||||||
|
const invalidateDeliveries = () => Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['/api/deliveries'] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['/api/payment-schedule'] }),
|
||||||
|
]);
|
||||||
|
|
||||||
// Fonction de vérification de facture
|
// Fonction de vérification de facture
|
||||||
const verifyInvoiceMutation = useMutation({
|
const verifyInvoiceMutation = useMutation({
|
||||||
mutationFn: async ({ deliveryId, invoiceReference, blNumber, forceRefresh }: { deliveryId: number; invoiceReference?: string; blNumber?: string; forceRefresh?: boolean; silent?: boolean }) => {
|
mutationFn: async ({ deliveryId, invoiceReference, blNumber, forceRefresh }: { deliveryId: number; invoiceReference?: string; blNumber?: string; forceRefresh?: boolean; silent?: boolean }) => {
|
||||||
@@ -148,42 +228,17 @@ export default function BLReconciliation() {
|
|||||||
|
|
||||||
// Pas de toast - affichage silencieux des coches vertes/rouges
|
// Pas de toast - affichage silencieux des coches vertes/rouges
|
||||||
|
|
||||||
// Auto-remplissage si facture trouvée (référence facture OU numéro BL)
|
// Auto-remplissage : le serveur (verify-invoice) enregistre déjà sur la
|
||||||
if (result.exists) {
|
// livraison la référence, les montants et l'échéance trouvés. Il suffit de
|
||||||
const updateData: any = {};
|
// recharger la liste, une seule fois quand la file est vidée : sinon
|
||||||
|
// chaque résultat relancerait un refetch complet, qui relancerait l'effet
|
||||||
// Ajouter la référence de facture SEULEMENT si trouvée via BL (pas déjà renseignée)
|
// de vérification automatique.
|
||||||
if (result.invoiceReference && result.matchType === 'bl_number') {
|
if (result.exists && (
|
||||||
updateData.invoiceReference = result.invoiceReference;
|
(result.invoiceReference && result.matchType === 'bl_number') ||
|
||||||
}
|
(result.invoiceAmount !== undefined && result.invoiceAmount !== null) ||
|
||||||
|
result.dueDate
|
||||||
// TOUJOURS mettre à jour le montant si disponible (peu importe le matchType)
|
)) {
|
||||||
if (result.invoiceAmount !== undefined && result.invoiceAmount !== null) {
|
needsCacheInvalidationRef.current = true;
|
||||||
updateData.invoiceAmount = result.invoiceAmount;
|
|
||||||
}
|
|
||||||
|
|
||||||
// TOUJOURS mettre à jour la date d'échéance si disponible (peu importe le matchType)
|
|
||||||
if (result.dueDate) {
|
|
||||||
updateData.dueDate = result.dueDate;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
console.log('📝 Auto-remplissage:', { deliveryId: variables.deliveryId, updateData, matchType: result.matchType });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ne faire l'appel que si on a des données à mettre à jour
|
|
||||||
if (Object.keys(updateData).length > 0) {
|
|
||||||
apiRequest(`/api/deliveries/${variables.deliveryId}`, "PUT", updateData)
|
|
||||||
.then(() => {
|
|
||||||
// Invalidation différée : sinon chaque auto-remplissage relance un
|
|
||||||
// refetch complet de la liste, qui relance l'effet, qui relance des
|
|
||||||
// vérifications... jusqu'à saturer le navigateur.
|
|
||||||
needsCacheInvalidationRef.current = true;
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('❌ Erreur auto-remplissage:', error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setVerifyingDeliveries(prev => {
|
setVerifyingDeliveries(prev => {
|
||||||
@@ -234,16 +289,6 @@ export default function BLReconciliation() {
|
|||||||
const { delivery, forceRefresh, silent } = next;
|
const { delivery, forceRefresh, silent } = next;
|
||||||
activeVerificationsRef.current += 1;
|
activeVerificationsRef.current += 1;
|
||||||
|
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
console.log('🔍 Déclenchement vérification:', {
|
|
||||||
deliveryId: delivery.id,
|
|
||||||
invoiceReference: delivery.invoiceReference,
|
|
||||||
blNumber: delivery.blNumber,
|
|
||||||
supplier: delivery.supplier?.name,
|
|
||||||
enAttente: verificationQueueRef.current.length
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
verifyInvoiceMutation
|
verifyInvoiceMutation
|
||||||
.mutateAsync({
|
.mutateAsync({
|
||||||
deliveryId: delivery.id,
|
deliveryId: delivery.id,
|
||||||
@@ -266,8 +311,7 @@ export default function BLReconciliation() {
|
|||||||
needsCacheInvalidationRef.current
|
needsCacheInvalidationRef.current
|
||||||
) {
|
) {
|
||||||
needsCacheInvalidationRef.current = false;
|
needsCacheInvalidationRef.current = false;
|
||||||
queryClient.invalidateQueries({ queryKey: ['/api/deliveries/bl'] });
|
invalidateDeliveries();
|
||||||
queryClient.invalidateQueries({ queryKey: ['/api/deliveries'] });
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -345,11 +389,14 @@ export default function BLReconciliation() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Récupérer les livraisons validées avec BL - CACHE INVALIDÉ après modifications
|
// Récupérer les livraisons livrées (onglets manuel et validées). Toute
|
||||||
const { data: deliveriesWithBL = [], isLoading, refetch } = useQuery({
|
// modification de livraison invalide ['/api/deliveries'], donc cette liste :
|
||||||
queryKey: ['/api/deliveries/bl', selectedStoreId],
|
// le cache par défaut (30 s) évite de la retélécharger à chaque visite.
|
||||||
|
const { data: deliveriesWithBL = NO_DELIVERIES, isLoading } = useQuery<any[]>({
|
||||||
|
queryKey: [...RECONCILIATION_QUERY_KEY, selectedStoreId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const params = new URLSearchParams({});
|
// Seules les livraisons livrées sont demandées (filtre appliqué en base)
|
||||||
|
const params = new URLSearchParams({ status: 'delivered' });
|
||||||
if (selectedStoreId && (user?.role === 'admin' || user?.role === 'directeur')) {
|
if (selectedStoreId && (user?.role === 'admin' || user?.role === 'directeur')) {
|
||||||
params.append('storeId', selectedStoreId.toString());
|
params.append('storeId', selectedStoreId.toString());
|
||||||
}
|
}
|
||||||
@@ -364,31 +411,23 @@ export default function BLReconciliation() {
|
|||||||
|
|
||||||
const deliveries = await response.json();
|
const deliveries = await response.json();
|
||||||
|
|
||||||
// Debug désactivé en production pour éviter latence
|
// Filtre conservé par sécurité si le serveur ignore le paramètre status
|
||||||
if (import.meta.env.DEV && deliveries[0]) {
|
|
||||||
console.log('🔍 DEBUG - Première livraison:', deliveries[0]);
|
|
||||||
}
|
|
||||||
const filtered = Array.isArray(deliveries) ? deliveries.filter((d: any) => d.status === 'delivered') : [];
|
const filtered = Array.isArray(deliveries) ? deliveries.filter((d: any) => d.status === 'delivered') : [];
|
||||||
|
|
||||||
return filtered.sort((a: any, b: any) => new Date(b.deliveredDate || b.updatedAt).getTime() - new Date(a.deliveredDate || a.updatedAt).getTime());
|
return filtered.sort((a: any, b: any) => new Date(b.deliveredDate || b.updatedAt).getTime() - new Date(a.deliveredDate || a.updatedAt).getTime());
|
||||||
},
|
},
|
||||||
enabled: !!user,
|
enabled: !!user,
|
||||||
staleTime: 0 // Éviter la mise en cache pour toujours avoir les dernières données BL
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// VÉRIFICATION AUTOMATIQUE AU CHARGEMENT avec système de cache
|
// VÉRIFICATION AUTOMATIQUE AU CHARGEMENT avec système de cache
|
||||||
//
|
//
|
||||||
// Cet effet ne dépend QUE des données (livraisons + fournisseurs) : y ajouter
|
// Cet effet ne dépend QUE des livraisons : y ajouter
|
||||||
// verificationResults/verifyingDeliveries le relançait à chaque résultat reçu,
|
// verificationResults/verifyingDeliveries le relançait à chaque résultat reçu,
|
||||||
// ce qui reprogrammait en boucle les mêmes vérifications.
|
// ce qui reprogrammait en boucle les mêmes vérifications.
|
||||||
// Le dédoublonnage s'appuie sur autoRequestedIdsRef (ref, pas state) pour être
|
// Le dédoublonnage s'appuie sur autoRequestedIdsRef (ref, pas state) pour être
|
||||||
// effectif immédiatement, sans attendre un re-rendu.
|
// effectif immédiatement, sans attendre un re-rendu.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!deliveriesWithBL.length || !suppliers.length) return;
|
if (!deliveriesWithBL.length) return;
|
||||||
|
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
console.log('🔄 Déclenchement vérifications automatiques...');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Résultats déduits localement (sans appel réseau) pour afficher les coches
|
// Résultats déduits localement (sans appel réseau) pour afficher les coches
|
||||||
const cachedResults: Record<number, any> = {};
|
const cachedResults: Record<number, any> = {};
|
||||||
@@ -450,19 +489,22 @@ export default function BLReconciliation() {
|
|||||||
|
|
||||||
return changed ? merged : prev;
|
return changed ? merged : prev;
|
||||||
});
|
});
|
||||||
}, [deliveriesWithBL, suppliers]);
|
}, [deliveriesWithBL]);
|
||||||
|
|
||||||
// Séparer les livraisons : non validées manuelles et toutes les validées
|
// Séparer les livraisons : non validées manuelles et toutes les validées.
|
||||||
const manualNotValidatedDeliveries = deliveriesWithBL.filter((delivery: any) => {
|
// Le fournisseur (dont automaticReconciliation) est joint à chaque livraison.
|
||||||
const supplier = suppliers.find(s => s.id === delivery.supplierId);
|
const manualNotValidatedDeliveries = useMemo(() => deliveriesWithBL.filter((delivery: any) => {
|
||||||
const isManual = supplier?.automaticReconciliation !== true;
|
const isManual = delivery.supplier?.automaticReconciliation !== true;
|
||||||
const isNotValidated = delivery.reconciled !== true && delivery.reconciled !== 1;
|
const isNotValidated = delivery.reconciled !== true && delivery.reconciled !== 1;
|
||||||
return isManual && isNotValidated;
|
return isManual && isNotValidated;
|
||||||
});
|
}), [deliveriesWithBL]);
|
||||||
|
|
||||||
const allValidatedDeliveries = deliveriesWithBL.filter((delivery: any) => {
|
// Fournisseurs en rapprochement automatique : toujours considérés validés,
|
||||||
return delivery.reconciled === true || delivery.reconciled === 1;
|
// même si reconciled n'a pas été posé (livraisons antérieures au passage en mode auto)
|
||||||
});
|
const allValidatedDeliveries = useMemo(() => deliveriesWithBL.filter((delivery: any) => {
|
||||||
|
const isAutomatic = delivery.supplier?.automaticReconciliation === true;
|
||||||
|
return isAutomatic || delivery.reconciled === true || delivery.reconciled === 1;
|
||||||
|
}), [deliveriesWithBL]);
|
||||||
|
|
||||||
// Fonctions de gestion
|
// Fonctions de gestion
|
||||||
const handleOpenModal = (delivery: any) => {
|
const handleOpenModal = (delivery: any) => {
|
||||||
@@ -477,9 +519,13 @@ export default function BLReconciliation() {
|
|||||||
|
|
||||||
const handleSaveReconciliation = async () => {
|
const handleSaveReconciliation = async () => {
|
||||||
try {
|
try {
|
||||||
// Force refetch immédiat pour mettre à jour l'affichage
|
// Mise à jour de l'affichage : seules les listes affichées sont rechargées.
|
||||||
await queryClient.refetchQueries({ queryKey: ['/api/deliveries/bl'] });
|
// cancelRefetch: false réutilise le rechargement déjà lancé par la modale
|
||||||
await queryClient.refetchQueries({ queryKey: ['/api/deliveries'] });
|
// (après l'enregistrement) au lieu d'en relancer un second.
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['/api/deliveries'] }, { cancelRefetch: false }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['/api/payment-schedule'] }),
|
||||||
|
]);
|
||||||
handleCloseModal();
|
handleCloseModal();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({
|
toast({
|
||||||
@@ -517,25 +563,6 @@ export default function BLReconciliation() {
|
|||||||
|
|
||||||
const handleCloseWaitingModal = () => {
|
const handleCloseWaitingModal = () => {
|
||||||
setShowWaitingModal(false);
|
setShowWaitingModal(false);
|
||||||
setProcessingSeconds(0);
|
|
||||||
if (processingTimeout) {
|
|
||||||
clearTimeout(processingTimeout);
|
|
||||||
setProcessingTimeout(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const startProcessingTimer = () => {
|
|
||||||
setProcessingSeconds(0);
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
setProcessingSeconds(prev => {
|
|
||||||
if (prev >= 60) {
|
|
||||||
clearInterval(interval);
|
|
||||||
return 60;
|
|
||||||
}
|
|
||||||
return prev + 1;
|
|
||||||
});
|
|
||||||
}, 1000);
|
|
||||||
setProcessingTimeout(interval);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
@@ -574,7 +601,6 @@ export default function BLReconciliation() {
|
|||||||
setShowInvoiceModal(false);
|
setShowInvoiceModal(false);
|
||||||
setShowWaitingModal(true);
|
setShowWaitingModal(true);
|
||||||
setIsUploading(true);
|
setIsUploading(true);
|
||||||
startProcessingTimer();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
@@ -609,19 +635,13 @@ export default function BLReconciliation() {
|
|||||||
|
|
||||||
// Relancer la vérification de la facture qui vient d'être traitée
|
// Relancer la vérification de la facture qui vient d'être traitée
|
||||||
try {
|
try {
|
||||||
// Invalidation des caches pour forcer le rechargement des données
|
// Recharger les livraisons (une seule requête pour la liste affichée)
|
||||||
queryClient.invalidateQueries({ queryKey: ['/api/deliveries/bl'] });
|
await invalidateDeliveries();
|
||||||
queryClient.invalidateQueries({ queryKey: ['/api/deliveries'] });
|
|
||||||
|
|
||||||
// Recharger les données
|
|
||||||
await refetch();
|
|
||||||
|
|
||||||
// Attendre un peu que les données soient à jour, puis vérifier cette livraison spécifique
|
// Attendre un peu que les données soient à jour, puis vérifier cette livraison spécifique
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
handleVerifyInvoice(selectedDeliveryForInvoice, true);
|
handleVerifyInvoice(selectedDeliveryForInvoice, true);
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
||||||
console.log('🔄 Vérification automatique relancée pour la livraison traitée par webhook');
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur lors de la relance de la vérification:', error);
|
console.error('Erreur lors de la relance de la vérification:', error);
|
||||||
}
|
}
|
||||||
@@ -658,28 +678,13 @@ export default function BLReconciliation() {
|
|||||||
// Et il faut qu'il y ait un magasin assigné avec un webhook
|
// Et il faut qu'il y ait un magasin assigné avec un webhook
|
||||||
const hasValidGroup = delivery.group && delivery.group.webhookUrl;
|
const hasValidGroup = delivery.group && delivery.group.webhookUrl;
|
||||||
|
|
||||||
// Debug uniquement en développement
|
|
||||||
if (import.meta.env.DEV && delivery && delivery.supplier?.name) {
|
|
||||||
console.log(`🔍 Debug bouton facture pour ${delivery.supplier.name}:`, {
|
|
||||||
isNotValidated,
|
|
||||||
hasNoInvoiceReference,
|
|
||||||
hasValidGroup,
|
|
||||||
shouldShow: (isNotValidated || hasNoInvoiceReference) && hasValidGroup
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (isNotValidated || hasNoInvoiceReference) && hasValidGroup;
|
return (isNotValidated || hasNoInvoiceReference) && hasValidGroup;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Email du fournisseur : donnée jointe à la livraison, avec repli sur la liste des fournisseurs
|
// Email du fournisseur : la fiche fournisseur complète est jointe à la livraison
|
||||||
const getSupplierEmail = (delivery: any): string => {
|
const getSupplierEmail = (delivery: any): string => {
|
||||||
const fromDelivery = delivery?.supplier?.email;
|
const email = delivery?.supplier?.email;
|
||||||
if (fromDelivery && String(fromDelivery).trim()) {
|
return email && String(email).trim() ? String(email).trim() : '';
|
||||||
return String(fromDelivery).trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
const supplier = suppliers.find((s: any) => s.id === delivery?.supplierId);
|
|
||||||
return supplier?.email ? String(supplier.email).trim() : '';
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Envoi de la demande de facture (PDF) / BL (Excel) au fournisseur.
|
// Envoi de la demande de facture (PDF) / BL (Excel) au fournisseur.
|
||||||
@@ -745,7 +750,7 @@ export default function BLReconciliation() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Mise à jour optimiste du cache local pour disparition immédiate
|
// Mise à jour optimiste du cache local pour disparition immédiate
|
||||||
queryClient.setQueryData(['/api/deliveries/bl', selectedStoreId], (oldData: any) => {
|
queryClient.setQueryData([...RECONCILIATION_QUERY_KEY, selectedStoreId], (oldData: any) => {
|
||||||
if (!oldData) return oldData;
|
if (!oldData) return oldData;
|
||||||
return oldData.map((d: any) =>
|
return oldData.map((d: any) =>
|
||||||
d.id === delivery.id
|
d.id === delivery.id
|
||||||
@@ -754,12 +759,11 @@ export default function BLReconciliation() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Force refetch pour synchroniser avec le serveur
|
// Synchronisation avec le serveur (listes affichées uniquement)
|
||||||
queryClient.refetchQueries({ queryKey: ['/api/deliveries/bl'] });
|
invalidateDeliveries();
|
||||||
queryClient.refetchQueries({ queryKey: ['/api/deliveries'] });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// En cas d'erreur, refetch pour annuler la mise à jour optimiste
|
// En cas d'erreur, rechargement pour resynchroniser la liste
|
||||||
queryClient.refetchQueries({ queryKey: ['/api/deliveries/bl'] });
|
queryClient.invalidateQueries({ queryKey: RECONCILIATION_QUERY_KEY });
|
||||||
toast({
|
toast({
|
||||||
title: "Erreur",
|
title: "Erreur",
|
||||||
description: "Impossible de valider le rapprochement",
|
description: "Impossible de valider le rapprochement",
|
||||||
@@ -793,9 +797,8 @@ export default function BLReconciliation() {
|
|||||||
description: "Rapprochement dévalidé avec succès",
|
description: "Rapprochement dévalidé avec succès",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Force refetch immédiat pour déplacer la facture dans l'onglet manuel
|
// Rechargement immédiat pour déplacer la facture dans l'onglet manuel
|
||||||
await queryClient.refetchQueries({ queryKey: ['/api/deliveries/bl'] });
|
await invalidateDeliveries();
|
||||||
await queryClient.refetchQueries({ queryKey: ['/api/deliveries'] });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({
|
toast({
|
||||||
title: "Erreur",
|
title: "Erreur",
|
||||||
@@ -828,17 +831,16 @@ export default function BLReconciliation() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Mise à jour optimiste du cache local pour disparition immédiate
|
// Mise à jour optimiste du cache local pour disparition immédiate
|
||||||
queryClient.setQueryData(['/api/deliveries/bl', selectedStoreId], (oldData: any) => {
|
queryClient.setQueryData([...RECONCILIATION_QUERY_KEY, selectedStoreId], (oldData: any) => {
|
||||||
if (!oldData) return oldData;
|
if (!oldData) return oldData;
|
||||||
return oldData.filter((d: any) => d.id !== deliveryId);
|
return oldData.filter((d: any) => d.id !== deliveryId);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Force refetch pour synchroniser avec le serveur
|
// Synchronisation avec le serveur (listes affichées uniquement)
|
||||||
queryClient.refetchQueries({ queryKey: ['/api/deliveries/bl'] });
|
invalidateDeliveries();
|
||||||
queryClient.refetchQueries({ queryKey: ['/api/deliveries'] });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// En cas d'erreur, refetch pour annuler la mise à jour optimiste
|
// En cas d'erreur, rechargement pour resynchroniser la liste
|
||||||
queryClient.refetchQueries({ queryKey: ['/api/deliveries/bl'] });
|
queryClient.invalidateQueries({ queryKey: RECONCILIATION_QUERY_KEY });
|
||||||
toast({
|
toast({
|
||||||
title: "Erreur",
|
title: "Erreur",
|
||||||
description: "Impossible de supprimer la livraison",
|
description: "Impossible de supprimer la livraison",
|
||||||
@@ -847,23 +849,17 @@ export default function BLReconciliation() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Filtrage des livraisons par recherche uniquement
|
// Filtrage par recherche, recalculé seulement quand la liste ou la recherche
|
||||||
const filterDeliveries = (deliveries: any[]) => {
|
// change ; la recherche différée garde la saisie fluide sur une longue liste
|
||||||
if (!searchTerm) return deliveries;
|
const deferredSearchTerm = useDeferredValue(searchTerm);
|
||||||
|
const filteredManualDeliveries = useMemo(
|
||||||
const searchLower = searchTerm.toLowerCase();
|
() => filterDeliveriesBySearch(manualNotValidatedDeliveries, deferredSearchTerm),
|
||||||
return deliveries.filter((delivery: any) => {
|
[manualNotValidatedDeliveries, deferredSearchTerm]
|
||||||
return (
|
);
|
||||||
delivery.supplier?.name?.toLowerCase().includes(searchLower) ||
|
const filteredValidatedDeliveries = useMemo(
|
||||||
delivery.blNumber?.toLowerCase().includes(searchLower) ||
|
() => filterDeliveriesBySearch(allValidatedDeliveries, deferredSearchTerm),
|
||||||
delivery.invoiceReference?.toLowerCase().includes(searchLower) ||
|
[allValidatedDeliveries, deferredSearchTerm]
|
||||||
delivery.group?.name?.toLowerCase().includes(searchLower)
|
);
|
||||||
);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const filteredManualDeliveries = filterDeliveries(manualNotValidatedDeliveries);
|
|
||||||
const filteredValidatedDeliveries = filterDeliveries(allValidatedDeliveries);
|
|
||||||
|
|
||||||
// Pagination pour les rapprochements manuels
|
// Pagination pour les rapprochements manuels
|
||||||
const {
|
const {
|
||||||
@@ -923,23 +919,41 @@ export default function BLReconciliation() {
|
|||||||
<Search className="w-4 h-4 mr-1" />
|
<Search className="w-4 h-4 mr-1" />
|
||||||
Vérifier toutes les factures
|
Vérifier toutes les factures
|
||||||
</Button>
|
</Button>
|
||||||
|
{user?.role === 'admin' && (
|
||||||
|
<Button
|
||||||
|
onClick={() => setShowBapModal(true)}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="text-xs sm:text-sm"
|
||||||
|
>
|
||||||
|
<FileUp className="w-4 h-4 mr-1" />
|
||||||
|
Envoyer un BAP
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Onglets */}
|
{/* Onglets */}
|
||||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||||
<TabsList className="grid w-full grid-cols-2">
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
<TabsTrigger value="manual" className="flex items-center space-x-2">
|
{/* Libellés courts sur téléphone : les libellés complets débordaient */}
|
||||||
<Edit className="w-4 h-4" />
|
<TabsTrigger value="manual" className="flex items-center gap-1.5 sm:gap-2 min-w-0 px-2">
|
||||||
<span>Rapprochement Manuel</span>
|
<Edit className="hidden sm:block w-4 h-4 shrink-0" />
|
||||||
<Badge variant="secondary" className="ml-2">
|
<span className="truncate">
|
||||||
|
<span className="sm:hidden">À rapprocher</span>
|
||||||
|
<span className="hidden sm:inline">Rapprochement Manuel</span>
|
||||||
|
</span>
|
||||||
|
<Badge variant="secondary" className="shrink-0">
|
||||||
{manualNotValidatedDeliveries.length}
|
{manualNotValidatedDeliveries.length}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="validated" className="flex items-center space-x-2">
|
<TabsTrigger value="validated" className="flex items-center gap-1.5 sm:gap-2 min-w-0 px-2">
|
||||||
<CheckCircle className="w-4 h-4" />
|
<CheckCircle className="hidden sm:block w-4 h-4 shrink-0" />
|
||||||
<span>Livraisons Validées</span>
|
<span className="truncate">
|
||||||
<Badge variant="secondary" className="ml-2">
|
<span className="sm:hidden">Validées</span>
|
||||||
|
<span className="hidden sm:inline">Livraisons Validées</span>
|
||||||
|
</span>
|
||||||
|
<Badge variant="secondary" className="shrink-0">
|
||||||
{allValidatedDeliveries.length}
|
{allValidatedDeliveries.length}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
@@ -1419,21 +1433,11 @@ export default function BLReconciliation() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
{paginatedValidatedDeliveries.map((delivery: any) => {
|
{paginatedValidatedDeliveries.map((delivery: any) => {
|
||||||
const supplier = suppliers.find(s => s.id === delivery.supplierId);
|
const isAutomatic = delivery.supplier?.automaticReconciliation === true;
|
||||||
const isAutomatic = supplier?.automaticReconciliation === true;
|
|
||||||
const ecart = delivery.blAmount && delivery.invoiceAmount ?
|
const ecart = delivery.blAmount && delivery.invoiceAmount ?
|
||||||
((parseFloat(delivery.invoiceAmount) - parseFloat(delivery.blAmount)) / parseFloat(delivery.blAmount) * 100).toFixed(1) :
|
((parseFloat(delivery.invoiceAmount) - parseFloat(delivery.blAmount)) / parseFloat(delivery.blAmount) * 100).toFixed(1) :
|
||||||
null;
|
null;
|
||||||
|
|
||||||
// Log pour déboguer le problème des commentaires
|
|
||||||
if (import.meta.env.DEV && delivery.reconciliationCommentsCount) {
|
|
||||||
console.log('🔍 Validated delivery comments:', {
|
|
||||||
deliveryId: delivery.id,
|
|
||||||
commentsCount: delivery.reconciliationCommentsCount,
|
|
||||||
hasComments: delivery.reconciliationCommentsCount > 0
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr key={delivery.id} className="hover:bg-gray-50 bg-green-50">
|
<tr key={delivery.id} className="hover:bg-gray-50 bg-green-50">
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
@@ -1690,36 +1694,7 @@ export default function BLReconciliation() {
|
|||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="grid gap-6 py-6">
|
<div className="grid gap-6 py-6">
|
||||||
<div className="text-center">
|
<WaitingProgress />
|
||||||
<div className="w-16 h-16 mx-auto mb-4 relative">
|
|
||||||
<div className="w-16 h-16 border-4 border-gray-200 border-t-blue-600 rounded-full animate-spin"></div>
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
|
||||||
<span className="text-xs font-bold text-blue-600">{processingSeconds}s</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h3 className="font-medium text-gray-900">
|
|
||||||
Traitement de votre facture en cours...
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-gray-600">
|
|
||||||
Le workflow peut prendre jusqu'à 1 minute.
|
|
||||||
<br />
|
|
||||||
Veuillez patienter.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 bg-gray-100 rounded-full h-2 w-full">
|
|
||||||
<div
|
|
||||||
className="bg-blue-600 h-2 rounded-full transition-all duration-1000 ease-out"
|
|
||||||
style={{ width: `${Math.min((processingSeconds / 60) * 100, 100)}%` }}
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-2 text-xs text-gray-500">
|
|
||||||
{processingSeconds < 60 ? `${60 - processingSeconds}s restantes (max)` : 'Finalisation...'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectedDeliveryForInvoice && (
|
{selectedDeliveryForInvoice && (
|
||||||
<div className="bg-blue-50 p-3 rounded-md">
|
<div className="bg-blue-50 p-3 rounded-md">
|
||||||
@@ -1783,6 +1758,10 @@ export default function BLReconciliation() {
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{user?.role === 'admin' && (
|
||||||
|
<BapUploadModal open={showBapModal} onOpenChange={setShowBapModal} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -54,11 +54,19 @@ export default function BackupManager() {
|
|||||||
const canManageBackups = hasPermission(user?.role || '', 'backups', 'manage');
|
const canManageBackups = hasPermission(user?.role || '', 'backups', 'manage');
|
||||||
|
|
||||||
// Fetch backup list
|
// Fetch backup list
|
||||||
|
// Rafraîchissement automatique, suspendu quand l'onglet du navigateur est en
|
||||||
|
// arrière-plan (comportement par défaut de React Query) : toutes les 30 s tant
|
||||||
|
// qu'une sauvegarde est en cours, sinon toutes les 5 min (la liste ne change
|
||||||
|
// qu'à la sauvegarde automatique ou après une action, déjà suivie d'un rechargement)
|
||||||
const { data: backups = [], isLoading, error } = useQuery({
|
const { data: backups = [], isLoading, error } = useQuery({
|
||||||
queryKey: ['/api/backups'],
|
queryKey: ['/api/backups'],
|
||||||
queryFn: () => apiRequest('/api/backups'),
|
queryFn: () => apiRequest('/api/backups'),
|
||||||
enabled: canManageBackups,
|
enabled: canManageBackups,
|
||||||
refetchInterval: 30000, // Refresh every 30 seconds
|
refetchInterval: (query) => {
|
||||||
|
const list = query.state.data;
|
||||||
|
const hasRunningBackup = Array.isArray(list) && list.some((backup: BackupFile) => backup.status === 'creating');
|
||||||
|
return hasRunningBackup ? 30 * 1000 : 5 * 60 * 1000;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch utilities configuration
|
// Fetch utilities configuration
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient, keepPreviousData } from "@tanstack/react-query";
|
||||||
import { ChevronLeft, ChevronRight, Plus, RefreshCw, Calendar as CalendarIcon } from "lucide-react";
|
import { ChevronLeft, ChevronRight, Plus, RefreshCw, Calendar as CalendarIcon } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import CalendarGrid from "@/components/CalendarGrid";
|
import CalendarGrid from "@/components/CalendarGrid";
|
||||||
@@ -16,6 +16,9 @@ import { format, startOfMonth, endOfMonth } from "date-fns";
|
|||||||
import { fr } from "date-fns/locale";
|
import { fr } from "date-fns/locale";
|
||||||
import { hasPermission } from "@/lib/permissions";
|
import { hasPermission } from "@/lib/permissions";
|
||||||
|
|
||||||
|
// Référence stable tant que les données ne sont pas chargées (CalendarGrid est mémorisé)
|
||||||
|
const NO_ITEMS: any[] = [];
|
||||||
|
|
||||||
export default function Calendar() {
|
export default function Calendar() {
|
||||||
const { user } = useAuthUnified();
|
const { user } = useAuthUnified();
|
||||||
const { selectedStoreId } = useStore();
|
const { selectedStoreId } = useStore();
|
||||||
@@ -30,36 +33,20 @@ export default function Calendar() {
|
|||||||
const [selectedItem, setSelectedItem] = useState<any>(null);
|
const [selectedItem, setSelectedItem] = useState<any>(null);
|
||||||
|
|
||||||
|
|
||||||
const monthStart = startOfMonth(currentDate);
|
const startDate = format(startOfMonth(currentDate), 'yyyy-MM-dd');
|
||||||
const monthEnd = endOfMonth(currentDate);
|
const endDate = format(endOfMonth(currentDate), 'yyyy-MM-dd');
|
||||||
|
|
||||||
// Fetch orders and deliveries for the current month with store filtering
|
// Fetch orders and deliveries for the current month with store filtering
|
||||||
const { data: orders = [], isLoading: loadingOrders } = useQuery({
|
const { data: orders = NO_ITEMS, isLoading: loadingOrders, isPlaceholderData: ordersPlaceholder } = useQuery({
|
||||||
queryKey: ['/api/orders', selectedStoreId, {
|
queryKey: ['/api/orders', selectedStoreId, { startDate, endDate }],
|
||||||
startDate: format(monthStart, 'yyyy-MM-dd'),
|
|
||||||
endDate: format(monthEnd, 'yyyy-MM-dd')
|
|
||||||
}],
|
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({ startDate, endDate });
|
||||||
startDate: format(monthStart, 'yyyy-MM-dd'),
|
|
||||||
endDate: format(monthEnd, 'yyyy-MM-dd')
|
|
||||||
});
|
|
||||||
// CRITICAL FIX: Appliquer le filtrage par storeId pour TOUS les rôles, pas seulement admin
|
// CRITICAL FIX: Appliquer le filtrage par storeId pour TOUS les rôles, pas seulement admin
|
||||||
if (selectedStoreId) {
|
if (selectedStoreId) {
|
||||||
params.append('storeId', selectedStoreId.toString());
|
params.append('storeId', selectedStoreId.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = `/api/orders?${params.toString()}`;
|
const response = await fetch(`/api/orders?${params.toString()}`, {
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
console.log('📅 Calendar fetching orders:', {
|
|
||||||
url,
|
|
||||||
selectedStoreId,
|
|
||||||
userRole: user?.role,
|
|
||||||
params: params.toString()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(url, {
|
|
||||||
credentials: 'include'
|
credentials: 'include'
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -68,9 +55,6 @@ export default function Calendar() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
console.log('📅 Calendar orders received:', Array.isArray(data) ? data.length : 'NOT_ARRAY', 'items');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Protection contre les données invalides en production
|
// Protection contre les données invalides en production
|
||||||
if (!Array.isArray(data)) {
|
if (!Array.isArray(data)) {
|
||||||
@@ -81,34 +65,22 @@ export default function Calendar() {
|
|||||||
},
|
},
|
||||||
staleTime: 5 * 60 * 1000, // 5 minutes de cache pour éviter la disparition des données
|
staleTime: 5 * 60 * 1000, // 5 minutes de cache pour éviter la disparition des données
|
||||||
gcTime: 10 * 60 * 1000, // 10 minutes en cache
|
gcTime: 10 * 60 * 1000, // 10 minutes en cache
|
||||||
|
// Changement de mois : garder la grille affichée pendant le chargement au lieu
|
||||||
|
// du spinner. Jamais au changement de magasin (données d'un autre magasin).
|
||||||
|
placeholderData: (previousData, previousQuery) =>
|
||||||
|
previousQuery?.queryKey[1] === selectedStoreId ? keepPreviousData(previousData) : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: deliveries = [], isLoading: loadingDeliveries } = useQuery({
|
const { data: deliveries = NO_ITEMS, isLoading: loadingDeliveries, isPlaceholderData: deliveriesPlaceholder } = useQuery({
|
||||||
queryKey: ['/api/deliveries', selectedStoreId, {
|
queryKey: ['/api/deliveries', selectedStoreId, { startDate, endDate }],
|
||||||
startDate: format(monthStart, 'yyyy-MM-dd'),
|
|
||||||
endDate: format(monthEnd, 'yyyy-MM-dd')
|
|
||||||
}],
|
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({ startDate, endDate });
|
||||||
startDate: format(monthStart, 'yyyy-MM-dd'),
|
|
||||||
endDate: format(monthEnd, 'yyyy-MM-dd')
|
|
||||||
});
|
|
||||||
// CRITICAL FIX: Appliquer le filtrage par storeId pour TOUS les rôles, pas seulement admin
|
// CRITICAL FIX: Appliquer le filtrage par storeId pour TOUS les rôles, pas seulement admin
|
||||||
if (selectedStoreId) {
|
if (selectedStoreId) {
|
||||||
params.append('storeId', selectedStoreId.toString());
|
params.append('storeId', selectedStoreId.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = `/api/deliveries?${params.toString()}`;
|
const response = await fetch(`/api/deliveries?${params.toString()}`, {
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
console.log('📅 Calendar fetching deliveries:', {
|
|
||||||
url,
|
|
||||||
selectedStoreId,
|
|
||||||
userRole: user?.role,
|
|
||||||
params: params.toString()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(url, {
|
|
||||||
credentials: 'include'
|
credentials: 'include'
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -117,9 +89,6 @@ export default function Calendar() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
console.log('📅 Calendar deliveries received:', Array.isArray(data) ? data.length : 'NOT_ARRAY', 'items');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Protection contre les données invalides en production
|
// Protection contre les données invalides en production
|
||||||
if (!Array.isArray(data)) {
|
if (!Array.isArray(data)) {
|
||||||
@@ -130,10 +99,13 @@ export default function Calendar() {
|
|||||||
},
|
},
|
||||||
staleTime: 5 * 60 * 1000, // 5 minutes de cache pour éviter la disparition des données
|
staleTime: 5 * 60 * 1000, // 5 minutes de cache pour éviter la disparition des données
|
||||||
gcTime: 10 * 60 * 1000, // 10 minutes en cache
|
gcTime: 10 * 60 * 1000, // 10 minutes en cache
|
||||||
|
// Même règle que pour les commandes : jamais au changement de magasin
|
||||||
|
placeholderData: (previousData, previousQuery) =>
|
||||||
|
previousQuery?.queryKey[1] === selectedStoreId ? keepPreviousData(previousData) : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch publicities for the current year
|
// Fetch publicities for the current year
|
||||||
const { data: publicities = [], isLoading: loadingPublicities } = useQuery({
|
const { data: publicities = NO_ITEMS } = useQuery({
|
||||||
queryKey: ['/api/ad-campaigns', currentDate.getFullYear(), selectedStoreId],
|
queryKey: ['/api/ad-campaigns', currentDate.getFullYear(), selectedStoreId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
@@ -142,17 +114,7 @@ export default function Calendar() {
|
|||||||
params.append('storeId', selectedStoreId.toString());
|
params.append('storeId', selectedStoreId.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = `/api/ad-campaigns?${params.toString()}`;
|
const response = await fetch(`/api/ad-campaigns?${params.toString()}`, {
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
console.log('📅 Calendar fetching publicities:', {
|
|
||||||
url,
|
|
||||||
selectedStoreId,
|
|
||||||
userRole: user?.role,
|
|
||||||
year: currentDate.getFullYear()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(url, {
|
|
||||||
credentials: 'include'
|
credentials: 'include'
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -161,11 +123,12 @@ export default function Calendar() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
console.log('📅 Calendar publicities received:', Array.isArray(data) ? data.length : 'NOT_ARRAY', 'items');
|
|
||||||
}
|
|
||||||
return Array.isArray(data) ? data : [];
|
return Array.isArray(data) ? data : [];
|
||||||
},
|
},
|
||||||
|
// Changement d'année : garder les publicités affichées pendant le chargement,
|
||||||
|
// sauf au changement de magasin
|
||||||
|
placeholderData: (previousData, previousQuery) =>
|
||||||
|
previousQuery?.queryKey[2] === selectedStoreId ? keepPreviousData(previousData) : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const navigateMonth = (direction: 'prev' | 'next') => {
|
const navigateMonth = (direction: 'prev' | 'next') => {
|
||||||
@@ -178,16 +141,18 @@ export default function Calendar() {
|
|||||||
setCurrentDate(newDate);
|
setCurrentDate(newDate);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDateClick = (date: Date) => {
|
// Gestionnaires stables : CalendarGrid (mémorisé) n'est pas re-rendu à
|
||||||
|
// l'ouverture ou à la fermeture des modales
|
||||||
|
const handleDateClick = useCallback((date: Date) => {
|
||||||
setSelectedDate(date);
|
setSelectedDate(date);
|
||||||
setShowQuickCreate(true);
|
setShowQuickCreate(true);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const handleItemClick = (item: any, type: 'order' | 'delivery') => {
|
const handleItemClick = useCallback((item: any, type: 'order' | 'delivery') => {
|
||||||
// Ne pas invalider le cache à l'ouverture pour éviter la disparition des données
|
// Ne pas invalider le cache à l'ouverture pour éviter la disparition des données
|
||||||
setSelectedItem({ ...item, type });
|
setSelectedItem({ ...item, type });
|
||||||
setShowOrderDetail(true);
|
setShowOrderDetail(true);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const handleCreateOrder = () => {
|
const handleCreateOrder = () => {
|
||||||
setShowQuickCreate(false);
|
setShowQuickCreate(false);
|
||||||
@@ -201,7 +166,10 @@ export default function Calendar() {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
const isLoading = loadingOrders || loadingDeliveries || loadingPublicities;
|
// La grille n'attend pas les publicités : elles s'affichent dès leur arrivée
|
||||||
|
const isLoading = loadingOrders || loadingDeliveries;
|
||||||
|
// Mois précédent encore affiché pendant le chargement du nouveau mois
|
||||||
|
const isMonthLoading = ordersPlaceholder || deliveriesPlaceholder;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col overflow-hidden">
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
@@ -284,16 +252,21 @@ export default function Calendar() {
|
|||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<CalendarGrid
|
<div
|
||||||
currentDate={currentDate}
|
className={`transition-opacity ${isMonthLoading ? 'opacity-60' : ''}`}
|
||||||
orders={orders || []}
|
aria-busy={isMonthLoading}
|
||||||
deliveries={deliveries || []}
|
>
|
||||||
publicities={publicities || []}
|
<CalendarGrid
|
||||||
selectedStoreId={selectedStoreId}
|
currentDate={currentDate}
|
||||||
userGroups={user?.userGroups || []}
|
orders={orders}
|
||||||
onDateClick={handleDateClick}
|
deliveries={deliveries}
|
||||||
onItemClick={handleItemClick}
|
publicities={publicities}
|
||||||
/>
|
selectedStoreId={selectedStoreId}
|
||||||
|
userGroups={user?.userGroups || NO_ITEMS}
|
||||||
|
onDateClick={handleDateClick}
|
||||||
|
onItemClick={handleItemClick}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -69,18 +69,14 @@ export default function Contacts() {
|
|||||||
queryKey: ["/api/suppliers"],
|
queryKey: ["/api/suppliers"],
|
||||||
});
|
});
|
||||||
|
|
||||||
const contactsQueryKey = effectiveGroupId
|
// Clé structurée : l'invalidation par préfixe ["/api/contacts"] des mutations
|
||||||
? [`/api/contacts?groupId=${effectiveGroupId}`]
|
// ci-dessous rafraîchit aussi la liste filtrée par magasin
|
||||||
: ["/api/contacts"];
|
|
||||||
|
|
||||||
const { data: contacts = [] } = useQuery<Contact[]>({
|
const { data: contacts = [] } = useQuery<Contact[]>({
|
||||||
queryKey: contactsQueryKey,
|
queryKey: ["/api/contacts", effectiveGroupId ?? null],
|
||||||
queryFn: async () => {
|
queryFn: () =>
|
||||||
const url = effectiveGroupId
|
apiRequest(effectiveGroupId ? `/api/contacts?groupId=${effectiveGroupId}` : "/api/contacts", "GET"),
|
||||||
? `/api/contacts?groupId=${effectiveGroupId}`
|
// Sans magasin, un non-admin ne voit pas la liste : pas de requête
|
||||||
: "/api/contacts";
|
enabled: !!user && (isAdmin || !!effectiveGroupId),
|
||||||
return await apiRequest(url, "GET");
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState, useMemo, useEffect } from "react";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient, keepPreviousData } from "@tanstack/react-query";
|
||||||
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -30,13 +30,12 @@ import {
|
|||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Pagination, usePagination } from "@/components/ui/pagination";
|
import { Pagination, usePagination } from "@/components/ui/pagination";
|
||||||
import { Plus, Edit, Trash2, Phone, PhoneCall, Printer, Eye, Package, AlertCircle, MessageSquare } from "lucide-react";
|
import { Plus, Edit, Trash2, Phone, PhoneCall, Printer, Eye, Package, AlertCircle, MessageSquare } from "lucide-react";
|
||||||
import JsBarcode from 'jsbarcode';
|
|
||||||
import { safeFormat, safeDate } from "@/lib/dateUtils";
|
import { safeFormat, safeDate } from "@/lib/dateUtils";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { fr } from "date-fns/locale";
|
import { fr } from "date-fns/locale";
|
||||||
import { apiRequest } from "@/lib/queryClient";
|
import { apiRequest } from "@/lib/queryClient";
|
||||||
import { toast } from "@/hooks/use-toast";
|
import { toast } from "@/hooks/use-toast";
|
||||||
import type { CustomerOrderWithRelations, Group } from "@shared/schema";
|
import type { CustomerOrderWithRelations } from "@shared/schema";
|
||||||
import { CustomerOrderForm } from "@/components/CustomerOrderForm";
|
import { CustomerOrderForm } from "@/components/CustomerOrderForm";
|
||||||
import { CustomerOrderDetails } from "@/components/CustomerOrderDetails";
|
import { CustomerOrderDetails } from "@/components/CustomerOrderDetails";
|
||||||
import { ConfirmationModal } from "@/components/ConfirmationModal";
|
import { ConfirmationModal } from "@/components/ConfirmationModal";
|
||||||
@@ -44,6 +43,10 @@ import ClientCallsModal from "@/components/modals/ClientCallsModal";
|
|||||||
import { useStore } from "@/contexts/StoreContext";
|
import { useStore } from "@/contexts/StoreContext";
|
||||||
import { usePermissions } from "@shared/permissions";
|
import { usePermissions } from "@shared/permissions";
|
||||||
|
|
||||||
|
// Référence stable tant que la liste n'est pas chargée (évite de recalculer
|
||||||
|
// filtre et tri à chaque rendu)
|
||||||
|
const NO_ORDERS: CustomerOrderWithRelations[] = [];
|
||||||
|
|
||||||
export default function CustomerOrders() {
|
export default function CustomerOrders() {
|
||||||
const { user } = useAuthUnified();
|
const { user } = useAuthUnified();
|
||||||
const { selectedStoreId } = useStore();
|
const { selectedStoreId } = useStore();
|
||||||
@@ -65,13 +68,11 @@ export default function CustomerOrders() {
|
|||||||
const [filterSupplier, setFilterSupplier] = useState<string>("all");
|
const [filterSupplier, setFilterSupplier] = useState<string>("all");
|
||||||
const [filterStatus, setFilterStatus] = useState<string>("all");
|
const [filterStatus, setFilterStatus] = useState<string>("all");
|
||||||
|
|
||||||
// Fetch groups for store filter
|
|
||||||
const { data: groups = [] } = useQuery<Group[]>({
|
|
||||||
queryKey: ['/api/groups'],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Query pour les appels clients en attente
|
// Query pour les appels clients en attente
|
||||||
const { data: pendingCalls = [], isLoading: isPendingCallsLoading } = useQuery<CustomerOrderWithRelations[]>({
|
// Le serveur refuse cette liste aux employés (403) : la requête et son
|
||||||
|
// rafraîchissement toutes les 30 s ne sont lancés que pour les autres rôles.
|
||||||
|
const canViewPendingCalls = !!user && user.role !== 'employee';
|
||||||
|
const { data: pendingCalls = NO_ORDERS, isLoading: isPendingCallsLoading } = useQuery<CustomerOrderWithRelations[]>({
|
||||||
queryKey: ['/api/customer-orders/pending-calls', selectedStoreId],
|
queryKey: ['/api/customer-orders/pending-calls', selectedStoreId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const pendingCallsUrl = `/api/customer-orders/pending-calls${selectedStoreId ? `?storeId=${selectedStoreId}` : ''}`;
|
const pendingCallsUrl = `/api/customer-orders/pending-calls${selectedStoreId ? `?storeId=${selectedStoreId}` : ''}`;
|
||||||
@@ -79,11 +80,11 @@ export default function CustomerOrders() {
|
|||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to fetch pending calls');
|
throw new Error('Failed to fetch pending calls');
|
||||||
}
|
}
|
||||||
const data = await response.json();
|
return response.json();
|
||||||
console.log('📞 Customer Orders - Pending calls received:', Array.isArray(data) ? data.length : 'NOT_ARRAY', 'items');
|
|
||||||
return data;
|
|
||||||
},
|
},
|
||||||
|
enabled: canViewPendingCalls,
|
||||||
refetchInterval: 30000, // Refetch every 30 seconds
|
refetchInterval: 30000, // Refetch every 30 seconds
|
||||||
|
refetchIntervalInBackground: false, // pas de rafraîchissement onglet masqué
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch suppliers for filter
|
// Fetch suppliers for filter
|
||||||
@@ -93,21 +94,20 @@ export default function CustomerOrders() {
|
|||||||
|
|
||||||
// Fetch customer orders with store filtering for admins
|
// Fetch customer orders with store filtering for admins
|
||||||
const customerOrdersUrl = `/api/customer-orders${selectedStoreId && user?.role === 'admin' ? `?storeId=${selectedStoreId}` : ''}`;
|
const customerOrdersUrl = `/api/customer-orders${selectedStoreId && user?.role === 'admin' ? `?storeId=${selectedStoreId}` : ''}`;
|
||||||
const { data: customerOrders = [], isLoading } = useQuery<CustomerOrderWithRelations[]>({
|
const { data: customerOrders = NO_ORDERS, isLoading, isPlaceholderData } = useQuery<CustomerOrderWithRelations[]>({
|
||||||
queryKey: [customerOrdersUrl, selectedStoreId],
|
queryKey: [customerOrdersUrl, selectedStoreId],
|
||||||
|
// Au changement de magasin, la liste précédente reste affichée (estompée,
|
||||||
|
// non cliquable, avec un indicateur) au lieu de l'écran « Chargement... »
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create mutation
|
// Create mutation
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: (data: any) => {
|
mutationFn: (data: any) => apiRequest('/api/customer-orders', 'POST', data),
|
||||||
console.log("🔥 CREATE MUTATION STARTED with data:", data);
|
onSuccess: () => {
|
||||||
return apiRequest('/api/customer-orders', 'POST', data);
|
// invalidateQueries recharge les listes affichées et marque les autres
|
||||||
},
|
// comme périmées (pas de second rechargement forcé)
|
||||||
onSuccess: (result) => {
|
|
||||||
console.log("✅ CREATE MUTATION SUCCESS:", result);
|
|
||||||
// Force refresh of the query with store context
|
|
||||||
queryClient.invalidateQueries({ predicate: (query) => query.queryKey[0]?.toString()?.includes('/api/customer-orders') ?? false });
|
queryClient.invalidateQueries({ predicate: (query) => query.queryKey[0]?.toString()?.includes('/api/customer-orders') ?? false });
|
||||||
queryClient.refetchQueries({ predicate: (query) => query.queryKey[0]?.toString()?.includes('/api/customer-orders') ?? false });
|
|
||||||
setShowCreateModal(false);
|
setShowCreateModal(false);
|
||||||
toast({
|
toast({
|
||||||
title: "Succès",
|
title: "Succès",
|
||||||
@@ -243,15 +243,7 @@ export default function CustomerOrders() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateOrder = (data: any) => {
|
const handleCreateOrder = (data: any) => {
|
||||||
console.log("🎯 HANDLE CREATE ORDER CALLED with data:", data);
|
|
||||||
console.log("🎯 createMutation state:", {
|
|
||||||
isIdle: createMutation.isIdle,
|
|
||||||
isPending: createMutation.isPending,
|
|
||||||
isError: createMutation.isError,
|
|
||||||
isSuccess: createMutation.isSuccess
|
|
||||||
});
|
|
||||||
createMutation.mutate(data);
|
createMutation.mutate(data);
|
||||||
console.log("🎯 createMutation.mutate CALLED");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEditOrder = (data: any) => {
|
const handleEditOrder = (data: any) => {
|
||||||
@@ -298,7 +290,8 @@ export default function CustomerOrders() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Fonction pour générer un code-barres scannable
|
// Fonction pour générer un code-barres scannable
|
||||||
const generateEAN13Barcode = (code: string): string => {
|
// (JsBarcode est chargé à la demande par handlePrintLabel)
|
||||||
|
const generateEAN13Barcode = (JsBarcode: typeof import('jsbarcode'), code: string): string => {
|
||||||
try {
|
try {
|
||||||
// Créer un canvas temporaire
|
// Créer un canvas temporaire
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
@@ -318,8 +311,6 @@ export default function CustomerOrders() {
|
|||||||
const checksum = calculateEAN13Checksum(processedCode);
|
const checksum = calculateEAN13Checksum(processedCode);
|
||||||
const ean13 = processedCode + checksum;
|
const ean13 = processedCode + checksum;
|
||||||
|
|
||||||
console.log('Code original:', code, 'EAN13 généré:', ean13);
|
|
||||||
|
|
||||||
// Générer le code-barres avec jsbarcode
|
// Générer le code-barres avec jsbarcode
|
||||||
JsBarcode(canvas, ean13, {
|
JsBarcode(canvas, ean13, {
|
||||||
format: "EAN13",
|
format: "EAN13",
|
||||||
@@ -378,11 +369,32 @@ export default function CustomerOrders() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePrintLabel = (order: CustomerOrderWithRelations) => {
|
const handlePrintLabel = async (order: CustomerOrderWithRelations) => {
|
||||||
// Ouvrir une nouvelle fenêtre pour imprimer l'étiquette
|
// Ouvrir une nouvelle fenêtre pour imprimer l'étiquette (immédiatement, dans
|
||||||
|
// le clic, pour ne pas être bloquée par le navigateur)
|
||||||
const printWindow = window.open('', '_blank');
|
const printWindow = window.open('', '_blank');
|
||||||
if (printWindow) {
|
if (printWindow) {
|
||||||
const barcodeDisplay = order.gencode ? generateEAN13Barcode(order.gencode) : '';
|
let barcodeDisplay = '';
|
||||||
|
if (order.gencode) {
|
||||||
|
// JsBarcode n'est téléchargé qu'au moment d'imprimer une étiquette
|
||||||
|
let JsBarcode: typeof import('jsbarcode');
|
||||||
|
try {
|
||||||
|
({ default: JsBarcode } = await import('jsbarcode'));
|
||||||
|
} catch (error) {
|
||||||
|
// Fichier introuvable (nouvelle version en ligne) ou réseau coupé :
|
||||||
|
// pas d'étiquette avec un faux code-barres
|
||||||
|
console.error('Chargement du générateur de code-barres impossible:', error);
|
||||||
|
printWindow.close();
|
||||||
|
toast({
|
||||||
|
title: "Erreur",
|
||||||
|
description: "Impossible de préparer l'étiquette, réessayez",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
barcodeDisplay = generateEAN13Barcode(JsBarcode, order.gencode);
|
||||||
|
}
|
||||||
|
if (printWindow.closed) return;
|
||||||
const isImageBarcode = barcodeDisplay.startsWith('data:');
|
const isImageBarcode = barcodeDisplay.startsWith('data:');
|
||||||
|
|
||||||
printWindow.document.write(`
|
printWindow.document.write(`
|
||||||
@@ -653,74 +665,67 @@ export default function CustomerOrders() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Filter orders based on search term, supplier, and status
|
// Filtre et tri recalculés seulement quand la liste ou un critère change
|
||||||
const filteredOrders = Array.isArray(customerOrders) ? customerOrders.filter(order => {
|
// (et non à chaque rendu, par exemple à chaque frappe dans le commentaire d'appel)
|
||||||
// Search term filter
|
const sortedOrders = useMemo(() => {
|
||||||
const matchesSearch = !searchTerm ||
|
if (!Array.isArray(customerOrders)) return NO_ORDERS;
|
||||||
order.customerName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
||||||
order.productDesignation.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
||||||
order.customerPhone.includes(searchTerm) ||
|
|
||||||
(order.productReference && order.productReference.toLowerCase().includes(searchTerm.toLowerCase())) ||
|
|
||||||
(order.gencode && order.gencode.toLowerCase().includes(searchTerm.toLowerCase())) ||
|
|
||||||
(order.supplier && order.supplier.name.toLowerCase().includes(searchTerm.toLowerCase()));
|
|
||||||
|
|
||||||
// Supplier filter
|
const search = searchTerm.toLowerCase();
|
||||||
const matchesSupplier = filterSupplier === "all" ||
|
|
||||||
(order.supplier && order.supplier.id.toString() === filterSupplier);
|
|
||||||
|
|
||||||
// Status filter
|
// Filter orders based on search term, supplier, and status
|
||||||
const matchesStatus = filterStatus === "all" || order.status === filterStatus;
|
const filteredOrders = customerOrders.filter(order => {
|
||||||
|
// Search term filter
|
||||||
|
const matchesSearch = !searchTerm ||
|
||||||
|
order.customerName.toLowerCase().includes(search) ||
|
||||||
|
order.productDesignation.toLowerCase().includes(search) ||
|
||||||
|
order.customerPhone.includes(searchTerm) ||
|
||||||
|
(order.productReference && order.productReference.toLowerCase().includes(search)) ||
|
||||||
|
(order.gencode && order.gencode.toLowerCase().includes(search)) ||
|
||||||
|
(order.supplier && order.supplier.name.toLowerCase().includes(search));
|
||||||
|
|
||||||
return matchesSearch && matchesSupplier && matchesStatus;
|
// Supplier filter
|
||||||
}) : [];
|
const matchesSupplier = filterSupplier === "all" ||
|
||||||
|
(order.supplier && order.supplier.id.toString() === filterSupplier);
|
||||||
|
|
||||||
// Sort orders - Active orders first, then "Retiré" orders last
|
// Status filter
|
||||||
const sortedOrders = (() => {
|
const matchesStatus = filterStatus === "all" || order.status === filterStatus;
|
||||||
// Séparer les commandes en deux groupes
|
|
||||||
const activeOrders = filteredOrders.filter(order => order.status !== "Retiré");
|
|
||||||
const retiredOrders = filteredOrders.filter(order => order.status === "Retiré");
|
|
||||||
|
|
||||||
// Fonction de tri commune
|
return matchesSearch && matchesSupplier && matchesStatus;
|
||||||
const sortFunction = (a: any, b: any) => {
|
});
|
||||||
let aValue: string | number;
|
|
||||||
let bValue: string | number;
|
|
||||||
|
|
||||||
|
// Valeur de tri calculée une seule fois par commande (et non à chaque comparaison)
|
||||||
|
const getSortValue = (order: CustomerOrderWithRelations): string | number => {
|
||||||
switch (sortBy) {
|
switch (sortBy) {
|
||||||
case "date":
|
|
||||||
const dateA = safeDate(a.createdAt);
|
|
||||||
const dateB = safeDate(b.createdAt);
|
|
||||||
aValue = dateA ? dateA.getTime() : 0;
|
|
||||||
bValue = dateB ? dateB.getTime() : 0;
|
|
||||||
break;
|
|
||||||
case "status":
|
case "status":
|
||||||
aValue = a.status;
|
return order.status;
|
||||||
bValue = b.status;
|
|
||||||
break;
|
|
||||||
case "supplier":
|
case "supplier":
|
||||||
aValue = a.supplier?.name || "";
|
return order.supplier?.name || "";
|
||||||
bValue = b.supplier?.name || "";
|
case "date":
|
||||||
break;
|
default: {
|
||||||
default:
|
const date = safeDate(order.createdAt);
|
||||||
const defaultDateA = safeDate(a.createdAt);
|
return date ? date.getTime() : 0;
|
||||||
const defaultDateB = safeDate(b.createdAt);
|
}
|
||||||
aValue = defaultDateA ? defaultDateA.getTime() : 0;
|
|
||||||
bValue = defaultDateB ? defaultDateB.getTime() : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sortOrder === "desc") {
|
|
||||||
return aValue > bValue ? -1 : aValue < bValue ? 1 : 0;
|
|
||||||
} else {
|
|
||||||
return aValue < bValue ? -1 : aValue > bValue ? 1 : 0;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Trier chaque groupe séparément
|
// Fonction de tri commune
|
||||||
const sortedActiveOrders = [...activeOrders].sort(sortFunction);
|
const sortGroup = (orders: CustomerOrderWithRelations[]) =>
|
||||||
const sortedRetiredOrders = [...retiredOrders].sort(sortFunction);
|
orders
|
||||||
|
.map(order => ({ order, value: getSortValue(order) }))
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (sortOrder === "desc") {
|
||||||
|
return a.value > b.value ? -1 : a.value < b.value ? 1 : 0;
|
||||||
|
} else {
|
||||||
|
return a.value < b.value ? -1 : a.value > b.value ? 1 : 0;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.map(entry => entry.order);
|
||||||
|
|
||||||
// Concaténer : commandes actives d'abord, puis retiré en dernier
|
// Sort orders - Active orders first, then "Retiré" orders last
|
||||||
return [...sortedActiveOrders, ...sortedRetiredOrders];
|
const activeOrders = filteredOrders.filter(order => order.status !== "Retiré");
|
||||||
})();
|
const retiredOrders = filteredOrders.filter(order => order.status === "Retiré");
|
||||||
|
return [...sortGroup(activeOrders), ...sortGroup(retiredOrders)];
|
||||||
|
}, [customerOrders, searchTerm, filterSupplier, filterStatus, sortBy, sortOrder]);
|
||||||
|
|
||||||
// Pagination
|
// Pagination
|
||||||
const {
|
const {
|
||||||
@@ -733,6 +738,12 @@ export default function CustomerOrders() {
|
|||||||
totalItems
|
totalItems
|
||||||
} = usePagination(sortedOrders, 10);
|
} = usePagination(sortedOrders, 10);
|
||||||
|
|
||||||
|
// Retour à la première page au changement de magasin (la liste précédente
|
||||||
|
// reste affichée pendant le chargement, sa longueur ne change donc pas)
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentPage(1);
|
||||||
|
}, [selectedStoreId, setCurrentPage]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-6">
|
||||||
{/* Alerte pour les appels clients en attente */}
|
{/* Alerte pour les appels clients en attente */}
|
||||||
@@ -823,9 +834,19 @@ export default function CustomerOrders() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-lg">
|
<CardTitle className="text-lg">
|
||||||
Commandes ({totalItems})
|
Commandes ({totalItems})
|
||||||
|
{isPlaceholderData && (
|
||||||
|
<span className="inline-flex items-center gap-1 ml-2 text-xs font-normal text-gray-500" role="status">
|
||||||
|
<span className="animate-spin rounded-full h-3 w-3 border-b-2 border-primary" />
|
||||||
|
Mise à jour…
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
{/* Estompé et non cliquable tant que la liste du nouveau magasin n'est pas arrivée */}
|
||||||
|
<CardContent
|
||||||
|
className={isPlaceholderData ? "opacity-60 pointer-events-none transition-opacity" : ""}
|
||||||
|
aria-busy={isPlaceholderData}
|
||||||
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div>Chargement...</div>
|
<div>Chargement...</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
+111
-163
@@ -1,54 +1,47 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useMemo } from "react";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
||||||
import { useStore } from "@/contexts/StoreContext";
|
import { useStore } from "@/contexts/StoreContext";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
import { Calendar, Package, ShoppingCart, TrendingUp, Clock, MapPin, User, AlertTriangle, CheckCircle, Truck, FileText, BarChart3, Megaphone, Shield, XCircle, CheckSquare, Circle, Info, Star, Sparkles, X } from "lucide-react";
|
import { Calendar, Package, Clock, User, AlertTriangle, Truck, BarChart3, Megaphone, XCircle, CheckSquare, Circle, Info, Star, Sparkles, X } from "lucide-react";
|
||||||
import { safeFormat, safeDate } from "@/lib/dateUtils";
|
import { safeFormat, safeDate } from "@/lib/dateUtils";
|
||||||
import type { PublicityWithRelations, DashboardMessage } from "@shared/schema";
|
import type { PublicityWithRelations } from "@shared/schema";
|
||||||
import AnnouncementCard from "@/components/AnnouncementCard";
|
import AnnouncementCard, { getAnnouncementsQueryOptions, type AnnouncementWithRelations } from "@/components/AnnouncementCard";
|
||||||
import { DlcAlertModal } from "@/components/DlcAlertModal";
|
import { DlcAlertModal } from "@/components/DlcAlertModal";
|
||||||
|
|
||||||
|
// Annonce la plus récente créée il y a moins de 2 jours (null s'il n'y en a pas)
|
||||||
|
const selectRecentAnnouncement = (announcements: AnnouncementWithRelations[]): AnnouncementWithRelations | null => {
|
||||||
|
const twoDaysAgo = new Date();
|
||||||
|
twoDaysAgo.setDate(twoDaysAgo.getDate() - 2);
|
||||||
|
|
||||||
|
const recentAnnouncements = announcements.filter(announcement => {
|
||||||
|
if (!announcement.createdAt) return false;
|
||||||
|
const createdAt = safeDate(announcement.createdAt);
|
||||||
|
return createdAt && createdAt >= twoDaysAgo;
|
||||||
|
});
|
||||||
|
|
||||||
|
// filter() renvoie une copie : le tri ne modifie pas le cache partagé
|
||||||
|
return recentAnnouncements.length > 0
|
||||||
|
? recentAnnouncements.sort((a, b) => {
|
||||||
|
const dateA = safeDate(a.createdAt);
|
||||||
|
const dateB = safeDate(b.createdAt);
|
||||||
|
return (dateB?.getTime() || 0) - (dateA?.getTime() || 0);
|
||||||
|
})[0]
|
||||||
|
: null;
|
||||||
|
};
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const { user } = useAuthUnified();
|
const { user } = useAuthUnified();
|
||||||
const { selectedStoreId } = useStore();
|
const { selectedStoreId } = useStore();
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const [showAnnouncementModal, setShowAnnouncementModal] = useState(false);
|
const [showAnnouncementModal, setShowAnnouncementModal] = useState(false);
|
||||||
const [showDlcAlertModal, setShowDlcAlertModal] = useState(false);
|
const [showDlcAlertModal, setShowDlcAlertModal] = useState(false);
|
||||||
|
|
||||||
// Invalider les queries DLC quand le magasin change
|
// Toutes les requêtes attendent l'utilisateur (enabled: !!user) : le storeId envoyé
|
||||||
useEffect(() => {
|
// dépend de son rôle, et une réponse obtenue sans lui resterait en cache sous la
|
||||||
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products"] });
|
// clé du magasin sélectionné
|
||||||
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products/stats"] });
|
|
||||||
}, [selectedStoreId, queryClient]);
|
|
||||||
|
|
||||||
const { data: stats } = useQuery({
|
|
||||||
queryKey: ['/api/stats/monthly', selectedStoreId],
|
|
||||||
queryFn: async () => {
|
|
||||||
const currentDate = new Date();
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
year: currentDate.getFullYear().toString(),
|
|
||||||
month: (currentDate.getMonth() + 1).toString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (selectedStoreId && user?.role === 'admin') {
|
|
||||||
params.append('storeId', selectedStoreId.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(`/api/stats/monthly?${params.toString()}`, {
|
|
||||||
credentials: 'include'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to fetch stats');
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Statistiques annuelles pour la carte de délai moyen
|
// Statistiques annuelles pour la carte de délai moyen
|
||||||
const { data: yearlyStats } = useQuery({
|
const { data: yearlyStats } = useQuery({
|
||||||
@@ -73,65 +66,29 @@ export default function Dashboard() {
|
|||||||
|
|
||||||
return response.json();
|
return response.json();
|
||||||
},
|
},
|
||||||
|
enabled: !!user,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Construire les URLs pour récupérer toutes les données (pas de filtrage par date)
|
// Construire les URLs pour récupérer toutes les données (pas de filtrage par date)
|
||||||
const ordersUrl = `/api/orders${selectedStoreId && user?.role === 'admin' ? `?storeId=${selectedStoreId}` : ''}`;
|
const ordersUrl = `/api/orders${selectedStoreId && user?.role === 'admin' ? `?storeId=${selectedStoreId}` : ''}`;
|
||||||
const deliveriesUrl = `/api/deliveries${selectedStoreId && user?.role === 'admin' ? `?storeId=${selectedStoreId}` : ''}`;
|
const deliveriesUrl = `/api/deliveries${selectedStoreId && user?.role === 'admin' ? `?storeId=${selectedStoreId}` : ''}`;
|
||||||
const customerOrdersUrl = `/api/customer-orders${selectedStoreId && user?.role === 'admin' ? `?storeId=${selectedStoreId}` : ''}`;
|
|
||||||
|
|
||||||
// Utiliser les mêmes clés de cache que les autres pages pour assurer la cohérence
|
// Utiliser les mêmes clés de cache que les autres pages pour assurer la cohérence
|
||||||
const { data: allOrders = [] } = useQuery({
|
const { data: allOrders = [] } = useQuery({
|
||||||
queryKey: [ordersUrl, selectedStoreId],
|
queryKey: [ordersUrl, selectedStoreId],
|
||||||
|
enabled: !!user,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: allDeliveries = [] } = useQuery({
|
const { data: allDeliveries = [] } = useQuery({
|
||||||
queryKey: [deliveriesUrl, selectedStoreId],
|
queryKey: [deliveriesUrl, selectedStoreId],
|
||||||
|
enabled: !!user,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: customerOrders = [] } = useQuery({
|
// Dernière annonce récente (créée il y a moins de 2 jours), extraite de la
|
||||||
queryKey: [customerOrdersUrl, selectedStoreId],
|
// requête des annonces partagée avec AnnouncementCard
|
||||||
});
|
const { data: recentAnnouncement } = useQuery({
|
||||||
|
...getAnnouncementsQueryOptions(selectedStoreId, user?.role),
|
||||||
// Récupérer la dernière annonce récente (créée il y a moins de 2 jours)
|
select: selectRecentAnnouncement,
|
||||||
const { data: recentAnnouncement } = useQuery<DashboardMessage | null>({
|
|
||||||
queryKey: ['/api/announcements/recent', selectedStoreId],
|
|
||||||
queryFn: async () => {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (selectedStoreId && user?.role === 'admin') {
|
|
||||||
params.append('storeId', selectedStoreId.toString());
|
|
||||||
}
|
|
||||||
params.append('recent', 'true');
|
|
||||||
|
|
||||||
const response = await fetch(`/api/announcements?${params.toString()}`, {
|
|
||||||
credentials: 'include'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const announcements: DashboardMessage[] = await response.json();
|
|
||||||
|
|
||||||
// Filtrer les annonces créées il y a moins de 2 jours côté client
|
|
||||||
const twoDaysAgo = new Date();
|
|
||||||
twoDaysAgo.setDate(twoDaysAgo.getDate() - 2);
|
|
||||||
|
|
||||||
const recentAnnouncements = announcements.filter(announcement => {
|
|
||||||
if (!announcement.createdAt) return false;
|
|
||||||
const createdAt = safeDate(announcement.createdAt);
|
|
||||||
return createdAt && createdAt >= twoDaysAgo;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Retourner la plus récente
|
|
||||||
return recentAnnouncements.length > 0
|
|
||||||
? recentAnnouncements.sort((a, b) => {
|
|
||||||
const dateA = safeDate(a.createdAt);
|
|
||||||
const dateB = safeDate(b.createdAt);
|
|
||||||
return (dateB?.getTime() || 0) - (dateA?.getTime() || 0);
|
|
||||||
})[0]
|
|
||||||
: null;
|
|
||||||
},
|
|
||||||
enabled: !!user,
|
enabled: !!user,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -193,43 +150,48 @@ export default function Dashboard() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Récupérer les publicités à venir (chercher dans 2024 ET 2025) - TOUTES les publicités
|
// Récupérer les publicités à venir (années N-1, N et N+1) - TOUTES les publicités
|
||||||
const { data: upcomingPublicities = [] } = useQuery<PublicityWithRelations[]>({
|
const { data: upcomingPublicities = [] } = useQuery<PublicityWithRelations[]>({
|
||||||
queryKey: ['/api/ad-campaigns', 'upcoming'],
|
queryKey: ['/api/ad-campaigns', 'upcoming'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
// Essayer d'abord 2024, puis 2025 pour avoir toutes les publicités
|
|
||||||
const currentYear = new Date().getFullYear();
|
const currentYear = new Date().getFullYear();
|
||||||
const years = [currentYear - 1, currentYear, currentYear + 1];
|
const years = [currentYear - 1, currentYear, currentYear + 1];
|
||||||
let allPublicities: PublicityWithRelations[] = [];
|
|
||||||
|
|
||||||
for (const year of years) {
|
// Les trois années sont demandées en parallèle ; une année en échec est ignorée
|
||||||
|
const publicitiesByYear = await Promise.all(years.map(async (year): Promise<PublicityWithRelations[]> => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.append('year', year.toString());
|
params.append('year', year.toString());
|
||||||
// NE PAS filtrer par magasin - on veut toutes les publicités
|
// NE PAS filtrer par magasin - on veut toutes les publicités
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/ad-campaigns?${params}`, { credentials: 'include' });
|
const response = await fetch(`/api/ad-campaigns?${params}`, { credentials: 'include' });
|
||||||
if (response.ok) {
|
if (!response.ok) return [];
|
||||||
const yearPublicities = await response.json();
|
const yearPublicities = await response.json();
|
||||||
allPublicities = [...allPublicities, ...yearPublicities];
|
return Array.isArray(yearPublicities) ? yearPublicities : [];
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(`Erreur lors de la récupération des publicités ${year}:`, error);
|
console.warn(`Erreur lors de la récupération des publicités ${year}:`, error);
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}));
|
||||||
|
|
||||||
// Filtrer les publicités à venir et les trier par date
|
// Filtrer les publicités à venir et les trier par date
|
||||||
const futurePublicities = allPublicities
|
const now = new Date();
|
||||||
.filter((publicity: any) => new Date(publicity.startDate) > new Date())
|
const futurePublicities = publicitiesByYear
|
||||||
|
.flat()
|
||||||
|
.filter((publicity: any) => new Date(publicity.startDate) > now)
|
||||||
.sort((a: any, b: any) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime());
|
.sort((a: any, b: any) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime());
|
||||||
|
|
||||||
return futurePublicities;
|
return futurePublicities;
|
||||||
},
|
},
|
||||||
|
enabled: !!user,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch DLC stats
|
// Fetch DLC stats
|
||||||
|
// Clé distincte de celle de DlcPage, qui n'envoie storeId que pour l'admin (réponse
|
||||||
|
// différente pour un non-admin multi-magasins). Le préfixe reste couvert par les
|
||||||
|
// invalidations des mutations DLC.
|
||||||
const { data: dlcStats = { active: 0, expiringSoon: 0, expired: 0 } } = useQuery({
|
const { data: dlcStats = { active: 0, expiringSoon: 0, expired: 0 } } = useQuery({
|
||||||
queryKey: ["/api/dlc-products/stats", selectedStoreId],
|
queryKey: ["/api/dlc-products/stats", selectedStoreId, "dashboard"],
|
||||||
queryFn: () => {
|
queryFn: () => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
// Envoyer selectedStoreId pour tous les utilisateurs (le backend gère les permissions)
|
// Envoyer selectedStoreId pour tous les utilisateurs (le backend gère les permissions)
|
||||||
@@ -238,6 +200,7 @@ export default function Dashboard() {
|
|||||||
credentials: 'include'
|
credentials: 'include'
|
||||||
}).then(res => res.json());
|
}).then(res => res.json());
|
||||||
},
|
},
|
||||||
|
enabled: !!user,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Effet pour afficher automatiquement le modal DLC pour les utilisateurs non-admin
|
// Effet pour afficher automatiquement le modal DLC pour les utilisateurs non-admin
|
||||||
@@ -287,55 +250,66 @@ export default function Dashboard() {
|
|||||||
credentials: 'include'
|
credentials: 'include'
|
||||||
}).then(res => res.json());
|
}).then(res => res.json());
|
||||||
},
|
},
|
||||||
|
enabled: !!user,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Données dérivées pour les sections
|
// Données dérivées pour les sections, recalculées seulement quand les données
|
||||||
const recentOrders = Array.isArray(allOrders) ? allOrders
|
// changent. Les tris portent sur des copies : le cache React Query reste intact.
|
||||||
.sort((a: any, b: any) => {
|
|
||||||
const dateA = safeDate(a.createdAt);
|
|
||||||
const dateB = safeDate(b.createdAt);
|
|
||||||
return (dateB ? dateB.getTime() : 0) - (dateA ? dateA.getTime() : 0);
|
|
||||||
})
|
|
||||||
.slice(0, 4) : []; // Afficher les 4 dernières commandes
|
|
||||||
|
|
||||||
// Toutes les commandes en attente
|
// Toutes les commandes en attente, les plus récentes d'abord
|
||||||
const pendingOrders = Array.isArray(allOrders) ? allOrders
|
const pendingOrders = useMemo(() => {
|
||||||
.filter((order: any) => order.status === 'pending')
|
if (!Array.isArray(allOrders)) return [];
|
||||||
.sort((a: any, b: any) => {
|
const createdTime = (order: any) => safeDate(order.createdAt)?.getTime() ?? 0;
|
||||||
const dateA = safeDate(a.createdAt);
|
return allOrders
|
||||||
const dateB = safeDate(b.createdAt);
|
.filter((order: any) => order.status === 'pending')
|
||||||
return (dateB ? dateB.getTime() : 0) - (dateA ? dateA.getTime() : 0);
|
.sort((a: any, b: any) => createdTime(b) - createdTime(a));
|
||||||
}) : []; // Afficher toutes les commandes en attente
|
}, [allOrders]);
|
||||||
|
|
||||||
const upcomingDeliveries = Array.isArray(allDeliveries) ? allDeliveries
|
// Livraisons : un seul passage pour les livraisons prévues et les compteurs du mois
|
||||||
.filter((d: any) => d.status === 'planned')
|
const { upcomingDeliveries, deliveredThisMonth, totalPalettes } = useMemo(() => {
|
||||||
.sort((a: any, b: any) => new Date(a.scheduledDate).getTime() - new Date(b.scheduledDate).getTime())
|
const planned: any[] = [];
|
||||||
.slice(0, 4) : [];
|
let deliveredCount = 0;
|
||||||
|
let palettes = 0;
|
||||||
|
|
||||||
|
if (Array.isArray(allDeliveries)) {
|
||||||
|
const now = new Date();
|
||||||
|
for (const delivery of allDeliveries) {
|
||||||
|
if (delivery.status === 'planned') {
|
||||||
|
planned.push(delivery);
|
||||||
|
} else if (delivery.status === 'delivered') {
|
||||||
|
const deliveryDate = safeDate(delivery.deliveredDate || delivery.createdAt);
|
||||||
|
if (deliveryDate && deliveryDate.getMonth() === now.getMonth() && deliveryDate.getFullYear() === now.getFullYear()) {
|
||||||
|
deliveredCount++;
|
||||||
|
// Total des palettes pour les livraisons 'delivered' du mois en cours
|
||||||
|
if (delivery.unit === 'palettes') {
|
||||||
|
palettes += delivery.quantity || 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
planned.sort((a: any, b: any) => new Date(a.scheduledDate).getTime() - new Date(b.scheduledDate).getTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
upcomingDeliveries: planned.slice(0, 4),
|
||||||
|
deliveredThisMonth: deliveredCount,
|
||||||
|
totalPalettes: palettes,
|
||||||
|
};
|
||||||
|
}, [allDeliveries]);
|
||||||
|
|
||||||
|
// Tâches non terminées, les plus anciennes d'abord (5 au maximum)
|
||||||
|
const openTasks = useMemo(() => {
|
||||||
|
if (!Array.isArray(tasks)) return [];
|
||||||
|
const createdTime = (task: any) => safeDate(task.createdAt)?.getTime() ?? 0;
|
||||||
|
return tasks
|
||||||
|
.filter((task: any) => task.status !== 'completed')
|
||||||
|
.sort((a: any, b: any) => createdTime(a) - createdTime(b))
|
||||||
|
.slice(0, 5);
|
||||||
|
}, [tasks]);
|
||||||
|
|
||||||
// Calculs pour les statistiques
|
// Calculs pour les statistiques
|
||||||
const pendingOrdersCount = Array.isArray(allOrders) ? allOrders.filter((order: any) => order.status === 'pending').length : 0;
|
const pendingOrdersCount = pendingOrders.length;
|
||||||
const averageDeliveryTime = Math.round(yearlyStats?.averageDeliveryTime || 0);
|
const averageDeliveryTime = Math.round(yearlyStats?.averageDeliveryTime || 0);
|
||||||
const deliveredThisMonth = Array.isArray(allDeliveries) ? allDeliveries.filter((delivery: any) => {
|
|
||||||
const deliveryDate = safeDate(delivery.deliveredDate || delivery.createdAt);
|
|
||||||
if (!deliveryDate) return false;
|
|
||||||
const now = new Date();
|
|
||||||
return deliveryDate.getMonth() === now.getMonth() &&
|
|
||||||
deliveryDate.getFullYear() === now.getFullYear() &&
|
|
||||||
delivery.status === 'delivered';
|
|
||||||
}).length : 0;
|
|
||||||
|
|
||||||
// Calculer le total des palettes pour les livraisons 'delivered' du mois en cours
|
|
||||||
const totalPalettes = Array.isArray(allDeliveries) ? allDeliveries.reduce((total: number, delivery: any) => {
|
|
||||||
if (delivery.status === 'delivered' && delivery.unit === 'palettes') {
|
|
||||||
const deliveryDate = safeDate(delivery.deliveredDate || delivery.createdAt);
|
|
||||||
if (!deliveryDate) return total;
|
|
||||||
const now = new Date();
|
|
||||||
if (deliveryDate.getMonth() === now.getMonth() && deliveryDate.getFullYear() === now.getFullYear()) {
|
|
||||||
return total + (delivery.quantity || 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return total;
|
|
||||||
}, 0) : 0;
|
|
||||||
|
|
||||||
// Fonction pour obtenir la configuration des priorités (identique au module Tasks)
|
// Fonction pour obtenir la configuration des priorités (identique au module Tasks)
|
||||||
const getPriorityConfig = (priority: string) => {
|
const getPriorityConfig = (priority: string) => {
|
||||||
@@ -367,25 +341,6 @@ export default function Dashboard() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
// Statistiques pour les commandes clients
|
|
||||||
const ordersByStatus = {
|
|
||||||
pending: Array.isArray(allOrders) ? allOrders.filter((o: any) => o.status === 'pending').length : 0,
|
|
||||||
planned: Array.isArray(allOrders) ? allOrders.filter((o: any) => o.status === 'planned').length : 0,
|
|
||||||
delivered: Array.isArray(allOrders) ? allOrders.filter((o: any) => o.status === 'delivered').length : 0,
|
|
||||||
total: Array.isArray(allOrders) ? allOrders.length : 0
|
|
||||||
};
|
|
||||||
|
|
||||||
// Statistiques pour les commandes clients (nouveau module)
|
|
||||||
const customerOrderStats = {
|
|
||||||
waiting: Array.isArray(customerOrders) ? customerOrders.filter((o: any) => o.status === 'En attente de Commande').length : 0,
|
|
||||||
inProgress: Array.isArray(customerOrders) ? customerOrders.filter((o: any) => o.status === 'Commande en Cours').length : 0,
|
|
||||||
available: Array.isArray(customerOrders) ? customerOrders.filter((o: any) => o.status === 'Disponible').length : 0,
|
|
||||||
withdrawn: Array.isArray(customerOrders) ? customerOrders.filter((o: any) => o.status === 'Retiré').length : 0,
|
|
||||||
canceled: Array.isArray(customerOrders) ? customerOrders.filter((o: any) => o.status === 'Annulé').length : 0,
|
|
||||||
total: Array.isArray(customerOrders) ? customerOrders.length : 0
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 sm:p-6 space-y-4 sm:space-y-6">
|
<div className="p-4 sm:p-6 space-y-4 sm:space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -660,14 +615,7 @@ export default function Dashboard() {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3 p-6">
|
<CardContent className="space-y-3 p-6">
|
||||||
{Array.isArray(tasks) && tasks.length > 0 ? tasks
|
{Array.isArray(tasks) && tasks.length > 0 ? openTasks
|
||||||
.filter((task: any) => task.status !== 'completed')
|
|
||||||
.sort((a: any, b: any) => {
|
|
||||||
const dateA = safeDate(a.createdAt);
|
|
||||||
const dateB = safeDate(b.createdAt);
|
|
||||||
return (dateA ? dateA.getTime() : 0) - (dateB ? dateB.getTime() : 0);
|
|
||||||
})
|
|
||||||
.slice(0, 5)
|
|
||||||
.map((task: any) => {
|
.map((task: any) => {
|
||||||
const priorityConfig = getPriorityConfig(task.priority);
|
const priorityConfig = getPriorityConfig(task.priority);
|
||||||
const PriorityIcon = priorityConfig.icon;
|
const PriorityIcon = priorityConfig.icon;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -35,6 +35,9 @@ import ValidateDeliveryModal from "@/components/modals/ValidateDeliveryModal";
|
|||||||
import ConfirmDeleteModal from "@/components/modals/ConfirmDeleteModal";
|
import ConfirmDeleteModal from "@/components/modals/ConfirmDeleteModal";
|
||||||
import type { DeliveryWithRelations } from "@shared/schema";
|
import type { DeliveryWithRelations } from "@shared/schema";
|
||||||
|
|
||||||
|
// Référence stable pendant le chargement (pour la mémoïsation du filtrage)
|
||||||
|
const NO_DELIVERIES: DeliveryWithRelations[] = [];
|
||||||
|
|
||||||
export default function Deliveries() {
|
export default function Deliveries() {
|
||||||
const { user } = useAuthUnified();
|
const { user } = useAuthUnified();
|
||||||
const { selectedStoreId } = useStore();
|
const { selectedStoreId } = useStore();
|
||||||
@@ -79,38 +82,29 @@ export default function Deliveries() {
|
|||||||
// CRITICAL FIX: Appliquer le filtrage par storeId pour TOUS les rôles, pas seulement admin
|
// CRITICAL FIX: Appliquer le filtrage par storeId pour TOUS les rôles, pas seulement admin
|
||||||
const deliveriesUrl = `/api/deliveries${selectedStoreId ? `?storeId=${selectedStoreId}` : ''}`;
|
const deliveriesUrl = `/api/deliveries${selectedStoreId ? `?storeId=${selectedStoreId}` : ''}`;
|
||||||
|
|
||||||
const { data: deliveriesData = [], isLoading } = useQuery<DeliveryWithRelations[]>({
|
const { data: deliveriesData = NO_DELIVERIES, isLoading } = useQuery<DeliveryWithRelations[]>({
|
||||||
queryKey: ['/api/deliveries', selectedStoreId, user?.role],
|
queryKey: ['/api/deliveries', selectedStoreId, user?.role],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const url = deliveriesUrl;
|
const response = await fetch(deliveriesUrl, { credentials: 'include' });
|
||||||
console.log('🚚 Fetching deliveries from:', url);
|
|
||||||
const response = await fetch(url, { credentials: 'include' });
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to fetch deliveries');
|
throw new Error('Failed to fetch deliveries');
|
||||||
}
|
}
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
console.log('🚚 Deliveries received:', Array.isArray(data) ? data.length : 'NOT_ARRAY', 'items', data.slice(0, 2));
|
|
||||||
console.log('🚚 Sample delivery data:', data[0]);
|
|
||||||
return Array.isArray(data) ? data : [];
|
return Array.isArray(data) ? data : [];
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Production Bug Fix: Ensure array safety for all data operations
|
// Production Bug Fix: Ensure array safety for all data operations
|
||||||
const deliveries = Array.isArray(deliveriesData) ? deliveriesData : [];
|
const deliveries = Array.isArray(deliveriesData) ? deliveriesData : NO_DELIVERIES;
|
||||||
|
|
||||||
console.log('🚚 Deliveries Debug:', {
|
// Clé construite sur l'URL (tableau de bord admin avec magasin sélectionné).
|
||||||
isLoading,
|
// Sans magasin, elle vaut ['/api/deliveries'], déjà invalidée : la réinvalider
|
||||||
deliveriesCount: deliveries?.length,
|
// relancerait une seconde fois le chargement complet des livraisons.
|
||||||
deliveries: deliveries?.slice(0, 2),
|
const invalidateDeliveriesUrlKey = () => {
|
||||||
selectedStoreId,
|
if (selectedStoreId) {
|
||||||
deliveriesUrl
|
queryClient.invalidateQueries({ queryKey: [deliveriesUrl] });
|
||||||
});
|
}
|
||||||
|
};
|
||||||
const { data: groupsData = [] } = useQuery({
|
|
||||||
queryKey: ['/api/groups'],
|
|
||||||
});
|
|
||||||
|
|
||||||
const groups = Array.isArray(groupsData) ? groupsData : [];
|
|
||||||
|
|
||||||
|
|
||||||
const markControlValidatedMutation = useMutation({
|
const markControlValidatedMutation = useMutation({
|
||||||
@@ -123,7 +117,7 @@ export default function Deliveries() {
|
|||||||
description: "Contrôle validé avec succès",
|
description: "Contrôle validé avec succès",
|
||||||
});
|
});
|
||||||
queryClient.invalidateQueries({ queryKey: ['/api/deliveries'] });
|
queryClient.invalidateQueries({ queryKey: ['/api/deliveries'] });
|
||||||
queryClient.invalidateQueries({ queryKey: [deliveriesUrl] });
|
invalidateDeliveriesUrlKey();
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
if (isUnauthorizedError(error)) {
|
if (isUnauthorizedError(error)) {
|
||||||
@@ -148,8 +142,10 @@ export default function Deliveries() {
|
|||||||
});
|
});
|
||||||
// Invalider tous les caches liés aux livraisons
|
// Invalider tous les caches liés aux livraisons
|
||||||
queryClient.invalidateQueries({ queryKey: ['/api/deliveries'] });
|
queryClient.invalidateQueries({ queryKey: ['/api/deliveries'] });
|
||||||
queryClient.invalidateQueries({ queryKey: [deliveriesUrl] });
|
invalidateDeliveriesUrlKey();
|
||||||
queryClient.invalidateQueries({ queryKey: ['/api/stats/monthly'] });
|
queryClient.invalidateQueries({ queryKey: ['/api/stats/monthly'] });
|
||||||
|
// L'échéancier (cache de 5 min) liste les livraisons facturées
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['/api/payment-schedule'] });
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
if (isUnauthorizedError(error)) {
|
if (isUnauthorizedError(error)) {
|
||||||
@@ -171,16 +167,19 @@ export default function Deliveries() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const filteredDeliveries = Array.isArray(deliveries) ? deliveries.filter(delivery => {
|
// Filtrage recalculé seulement quand la liste, la recherche ou le statut changent
|
||||||
console.log('🔍 Filtering delivery:', delivery.id, { searchTerm, statusFilter });
|
const filteredDeliveries = useMemo(() => {
|
||||||
const matchesSearch = delivery.supplier?.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
const search = searchTerm.toLowerCase();
|
||||||
delivery.group?.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
return deliveries.filter(delivery => {
|
||||||
delivery.notes?.toLowerCase().includes(searchTerm.toLowerCase());
|
const matchesSearch = delivery.supplier?.name.toLowerCase().includes(search) ||
|
||||||
|
delivery.group?.name.toLowerCase().includes(search) ||
|
||||||
|
delivery.notes?.toLowerCase().includes(search);
|
||||||
|
|
||||||
const matchesStatus = statusFilter === "all" || delivery.status === statusFilter;
|
const matchesStatus = statusFilter === "all" || delivery.status === statusFilter;
|
||||||
|
|
||||||
return matchesSearch && matchesStatus;
|
return matchesSearch && matchesStatus;
|
||||||
}) : [];
|
});
|
||||||
|
}, [deliveries, searchTerm, statusFilter]);
|
||||||
|
|
||||||
// Pagination
|
// Pagination
|
||||||
const {
|
const {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useMemo, useRef } from "react";
|
import { useState, useEffect, useMemo, useRef } from "react";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useMutationState, useQueryClient, keepPreviousData } from "@tanstack/react-query";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -37,6 +37,14 @@ const dlcFormSchema = z.object({
|
|||||||
|
|
||||||
type DlcFormData = z.infer<typeof dlcFormSchema>;
|
type DlcFormData = z.infer<typeof dlcFormSchema>;
|
||||||
|
|
||||||
|
// Référence stable tant que la liste n'est pas chargée (évite de recalculer
|
||||||
|
// les listes d'impression à chaque rendu)
|
||||||
|
const NO_PRODUCTS: DlcProductWithRelations[] = [];
|
||||||
|
|
||||||
|
// Préfixe des actions faites depuis une ligne du tableau : sert à savoir
|
||||||
|
// quelles lignes ont une action en cours
|
||||||
|
const DLC_ROW_ACTION_KEY = "dlc-row-action";
|
||||||
|
|
||||||
export default function DlcPage() {
|
export default function DlcPage() {
|
||||||
const { user, isLoading: authLoading } = useAuthUnified();
|
const { user, isLoading: authLoading } = useAuthUnified();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@@ -76,7 +84,7 @@ export default function DlcPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Fetch DLC products - optimized with server-side search
|
// Fetch DLC products - optimized with server-side search
|
||||||
const { data: dlcProducts = [], isLoading: productsLoading } = useQuery({
|
const { data: dlcProducts = NO_PRODUCTS, isLoading: productsLoading, isPlaceholderData } = useQuery({
|
||||||
queryKey: ["/api/dlc-products", selectedStoreId, statusFilter, supplierFilter, debouncedSearchTerm],
|
queryKey: ["/api/dlc-products", selectedStoreId, statusFilter, supplierFilter, debouncedSearchTerm],
|
||||||
queryFn: () => {
|
queryFn: () => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
@@ -89,6 +97,11 @@ export default function DlcPage() {
|
|||||||
},
|
},
|
||||||
enabled: !authLoading,
|
enabled: !authLoading,
|
||||||
staleTime: 2 * 60 * 1000, // 2 minutes cache for DLC data
|
staleTime: 2 * 60 * 1000, // 2 minutes cache for DLC data
|
||||||
|
// Changement de filtre ou de recherche : le tableau précédent reste affiché
|
||||||
|
// (estompé, avec un indicateur) au lieu de disparaître. Jamais au
|
||||||
|
// changement de magasin (produits d'un autre magasin).
|
||||||
|
placeholderData: (previousData, previousQuery) =>
|
||||||
|
previousQuery?.queryKey[1] === selectedStoreId ? keepPreviousData(previousData) : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch DLC stats - optimized cache
|
// Fetch DLC stats - optimized cache
|
||||||
@@ -175,13 +188,30 @@ export default function DlcPage() {
|
|||||||
};
|
};
|
||||||
}, [gencodeValue, suppliers]);
|
}, [gencodeValue, suppliers]);
|
||||||
|
|
||||||
|
// Après une modification, la liste et les compteurs changent : seules les
|
||||||
|
// requêtes affichées sont rechargées, les autres (autres filtres, tableau de
|
||||||
|
// bord) sont seulement marquées périmées
|
||||||
|
const invalidateDlcData = () =>
|
||||||
|
Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products"], refetchType: "active" }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products/stats"], refetchType: "active" }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Actions en cours, par produit : seuls les boutons de la ligne concernée sont
|
||||||
|
// désactivés (et non ceux de toutes les lignes), y compris si plusieurs
|
||||||
|
// produits sont traités à la suite
|
||||||
|
const pendingRowActions = useMutationState({
|
||||||
|
filters: { mutationKey: [DLC_ROW_ACTION_KEY], status: "pending" },
|
||||||
|
select: (mutation) => `${String(mutation.options.mutationKey?.[1])}:${String(mutation.state.variables)}`,
|
||||||
|
});
|
||||||
|
const isRowActionPending = (action: string, productId: number) =>
|
||||||
|
pendingRowActions.includes(`${action}:${productId}`);
|
||||||
|
|
||||||
// Create mutation - optimized cache invalidation
|
// Create mutation - optimized cache invalidation
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: (data: InsertDlcProduct) => apiRequest("/api/dlc-products", "POST", data),
|
mutationFn: (data: InsertDlcProduct) => apiRequest("/api/dlc-products", "POST", data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
// Selective cache invalidation
|
invalidateDlcData();
|
||||||
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products"], exact: false });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products/stats"], exact: false });
|
|
||||||
toast({ title: "Produit DLC créé avec succès" });
|
toast({ title: "Produit DLC créé avec succès" });
|
||||||
setIsDialogOpen(false);
|
setIsDialogOpen(false);
|
||||||
form.reset();
|
form.reset();
|
||||||
@@ -201,8 +231,7 @@ export default function DlcPage() {
|
|||||||
mutationFn: ({ id, data }: { id: number; data: Partial<InsertDlcProduct> }) =>
|
mutationFn: ({ id, data }: { id: number; data: Partial<InsertDlcProduct> }) =>
|
||||||
apiRequest(`/api/dlc-products/${id}`, "PUT", data),
|
apiRequest(`/api/dlc-products/${id}`, "PUT", data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products"], exact: false });
|
invalidateDlcData();
|
||||||
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products/stats"], exact: false });
|
|
||||||
toast({ title: "Produit DLC mis à jour avec succès" });
|
toast({ title: "Produit DLC mis à jour avec succès" });
|
||||||
setIsDialogOpen(false);
|
setIsDialogOpen(false);
|
||||||
form.reset();
|
form.reset();
|
||||||
@@ -219,11 +248,12 @@ export default function DlcPage() {
|
|||||||
|
|
||||||
// Validate mutation - optimized cache invalidation
|
// Validate mutation - optimized cache invalidation
|
||||||
const validateMutation = useMutation({
|
const validateMutation = useMutation({
|
||||||
|
mutationKey: [DLC_ROW_ACTION_KEY, "validate"],
|
||||||
mutationFn: (id: number) => apiRequest(`/api/dlc-products/${id}/validate`, "POST"),
|
mutationFn: (id: number) => apiRequest(`/api/dlc-products/${id}/validate`, "POST"),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products"], exact: false });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products/stats"], exact: false });
|
|
||||||
toast({ title: "Produit validé avec succès" });
|
toast({ title: "Produit validé avec succès" });
|
||||||
|
// La ligne reste désactivée jusqu'au rechargement de la liste
|
||||||
|
return invalidateDlcData();
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
toast({
|
toast({
|
||||||
@@ -236,11 +266,12 @@ export default function DlcPage() {
|
|||||||
|
|
||||||
// Delete mutation - optimized cache invalidation
|
// Delete mutation - optimized cache invalidation
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
|
mutationKey: [DLC_ROW_ACTION_KEY, "delete"],
|
||||||
mutationFn: (id: number) => apiRequest(`/api/dlc-products/${id}`, "DELETE"),
|
mutationFn: (id: number) => apiRequest(`/api/dlc-products/${id}`, "DELETE"),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products"], exact: false });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products/stats"], exact: false });
|
|
||||||
toast({ title: "Produit supprimé avec succès" });
|
toast({ title: "Produit supprimé avec succès" });
|
||||||
|
// La ligne reste désactivée jusqu'au rechargement de la liste
|
||||||
|
return invalidateDlcData();
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
toast({
|
toast({
|
||||||
@@ -253,11 +284,12 @@ export default function DlcPage() {
|
|||||||
|
|
||||||
// Stock épuisé mutation - accessible à tous
|
// Stock épuisé mutation - accessible à tous
|
||||||
const markStockEpuiseMutation = useMutation({
|
const markStockEpuiseMutation = useMutation({
|
||||||
|
mutationKey: [DLC_ROW_ACTION_KEY, "stock-epuise"],
|
||||||
mutationFn: (id: number) => apiRequest(`/api/dlc-products/${id}/stock-epuise`, "PUT"),
|
mutationFn: (id: number) => apiRequest(`/api/dlc-products/${id}/stock-epuise`, "PUT"),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products"], exact: false });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products/stats"], exact: false });
|
|
||||||
toast({ title: "Produit marqué comme stock épuisé" });
|
toast({ title: "Produit marqué comme stock épuisé" });
|
||||||
|
// La ligne reste désactivée jusqu'au rechargement de la liste
|
||||||
|
return invalidateDlcData();
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
toast({
|
toast({
|
||||||
@@ -270,11 +302,12 @@ export default function DlcPage() {
|
|||||||
|
|
||||||
// Restaurer stock mutation - réservé aux admins, directeurs et managers
|
// Restaurer stock mutation - réservé aux admins, directeurs et managers
|
||||||
const restoreStockMutation = useMutation({
|
const restoreStockMutation = useMutation({
|
||||||
|
mutationKey: [DLC_ROW_ACTION_KEY, "restore-stock"],
|
||||||
mutationFn: (id: number) => apiRequest(`/api/dlc-products/${id}/restore-stock`, "PUT"),
|
mutationFn: (id: number) => apiRequest(`/api/dlc-products/${id}/restore-stock`, "PUT"),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products"], exact: false });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products/stats"], exact: false });
|
|
||||||
toast({ title: "Stock restauré avec succès" });
|
toast({ title: "Stock restauré avec succès" });
|
||||||
|
// La ligne reste désactivée jusqu'au rechargement de la liste
|
||||||
|
return invalidateDlcData();
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
toast({
|
toast({
|
||||||
@@ -287,11 +320,12 @@ export default function DlcPage() {
|
|||||||
|
|
||||||
// Marquer comme traité mutation - accessible à tous
|
// Marquer comme traité mutation - accessible à tous
|
||||||
const markProcessedMutation = useMutation({
|
const markProcessedMutation = useMutation({
|
||||||
|
mutationKey: [DLC_ROW_ACTION_KEY, "mark-processed"],
|
||||||
mutationFn: (id: number) => apiRequest(`/api/dlc-products/${id}/mark-processed`, "PUT"),
|
mutationFn: (id: number) => apiRequest(`/api/dlc-products/${id}/mark-processed`, "PUT"),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products"], exact: false });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products/stats"], exact: false });
|
|
||||||
toast({ title: "Produit marqué comme traité" });
|
toast({ title: "Produit marqué comme traité" });
|
||||||
|
// La ligne reste désactivée jusqu'au rechargement de la liste
|
||||||
|
return invalidateDlcData();
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
toast({
|
toast({
|
||||||
@@ -304,11 +338,12 @@ export default function DlcPage() {
|
|||||||
|
|
||||||
// Annuler traitement mutation - réservé aux admins, directeurs et managers
|
// Annuler traitement mutation - réservé aux admins, directeurs et managers
|
||||||
const unmarkProcessedMutation = useMutation({
|
const unmarkProcessedMutation = useMutation({
|
||||||
|
mutationKey: [DLC_ROW_ACTION_KEY, "unmark-processed"],
|
||||||
mutationFn: (id: number) => apiRequest(`/api/dlc-products/${id}/unmark-processed`, "PUT"),
|
mutationFn: (id: number) => apiRequest(`/api/dlc-products/${id}/unmark-processed`, "PUT"),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products"], exact: false });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products/stats"], exact: false });
|
|
||||||
toast({ title: "Traitement annulé avec succès" });
|
toast({ title: "Traitement annulé avec succès" });
|
||||||
|
// La ligne reste désactivée jusqu'au rechargement de la liste
|
||||||
|
return invalidateDlcData();
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
toast({
|
toast({
|
||||||
@@ -330,32 +365,17 @@ export default function DlcPage() {
|
|||||||
if (user?.userGroups?.[0]?.groupId) {
|
if (user?.userGroups?.[0]?.groupId) {
|
||||||
// Utilisateur avec groupe assigné : TOUJOURS utiliser ce groupe
|
// Utilisateur avec groupe assigné : TOUJOURS utiliser ce groupe
|
||||||
groupId = user.userGroups[0].groupId;
|
groupId = user.userGroups[0].groupId;
|
||||||
console.log("🎯 DLC Frontend: Using user's assigned group (PRIORITY):", groupId);
|
|
||||||
} else if (user?.role === 'admin' && selectedStoreId) {
|
} else if (user?.role === 'admin' && selectedStoreId) {
|
||||||
// Admin avec magasin sélectionné ET pas de groupe assigné
|
// Admin avec magasin sélectionné ET pas de groupe assigné
|
||||||
groupId = selectedStoreId;
|
groupId = selectedStoreId;
|
||||||
console.log("🎯 DLC Frontend: Using admin selected store:", groupId);
|
|
||||||
} else if (user?.role === 'admin' && stores?.[0]?.id) {
|
} else if (user?.role === 'admin' && stores?.[0]?.id) {
|
||||||
// Admin sans sélection : premier magasin disponible
|
// Admin sans sélection : premier magasin disponible
|
||||||
groupId = stores[0].id;
|
groupId = stores[0].id;
|
||||||
console.log("🎯 DLC Frontend: Using first available store for admin:", groupId);
|
|
||||||
} else {
|
} else {
|
||||||
// Fallback d'urgence
|
// Fallback d'urgence
|
||||||
groupId = 1;
|
groupId = 1;
|
||||||
console.log("🚨 DLC Frontend: Using emergency fallback groupId:", groupId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("🏪 DLC GroupId Selection DEBUG:", {
|
|
||||||
userRole: user?.role,
|
|
||||||
selectedStoreId,
|
|
||||||
userGroups: user?.userGroups?.map((ug: any) => ({groupId: ug.groupId, groupName: ug.group?.name})),
|
|
||||||
availableStores: stores.map((s: any) => ({id: s.id, name: s.name})),
|
|
||||||
userGroupsRaw: user?.userGroups,
|
|
||||||
firstUserGroup: user?.userGroups?.[0],
|
|
||||||
finalGroupId: groupId,
|
|
||||||
logicPath: !groupId ? 'need-fallback' : 'already-set'
|
|
||||||
});
|
|
||||||
|
|
||||||
const dlcData: any = {
|
const dlcData: any = {
|
||||||
...data,
|
...data,
|
||||||
name: data.productName, // Copier productName vers name (requis dans la DB)
|
name: data.productName, // Copier productName vers name (requis dans la DB)
|
||||||
@@ -615,7 +635,7 @@ export default function DlcPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Products are already filtered server-side, no client filtering needed
|
// Products are already filtered server-side, no client filtering needed
|
||||||
const filteredProducts = dlcProducts || [];
|
const filteredProducts = dlcProducts || NO_PRODUCTS;
|
||||||
|
|
||||||
// Memoized calculations for print functions
|
// Memoized calculations for print functions
|
||||||
const { expiringSoonProducts, expiredProducts } = useMemo(() => {
|
const { expiringSoonProducts, expiredProducts } = useMemo(() => {
|
||||||
@@ -647,6 +667,13 @@ export default function DlcPage() {
|
|||||||
totalItems
|
totalItems
|
||||||
} = usePagination(filteredProducts, 10);
|
} = usePagination(filteredProducts, 10);
|
||||||
|
|
||||||
|
// Retour à la première page quand le magasin, un filtre ou la recherche change
|
||||||
|
// (le tableau précédent reste affiché pendant le chargement : sa longueur ne
|
||||||
|
// change pas forcément)
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentPage(1);
|
||||||
|
}, [selectedStoreId, statusFilter, supplierFilter, debouncedSearchTerm, setCurrentPage]);
|
||||||
|
|
||||||
if (authLoading) {
|
if (authLoading) {
|
||||||
return <div className="flex justify-center items-center h-64">Chargement...</div>;
|
return <div className="flex justify-center items-center h-64">Chargement...</div>;
|
||||||
}
|
}
|
||||||
@@ -926,7 +953,15 @@ export default function DlcPage() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<CardTitle>Produits DLC ({totalItems})</CardTitle>
|
<CardTitle>
|
||||||
|
Produits DLC ({totalItems})
|
||||||
|
{isPlaceholderData && (
|
||||||
|
<span className="inline-flex items-center gap-1 ml-2 text-xs font-normal text-gray-500" role="status">
|
||||||
|
<span className="animate-spin rounded-full h-3 w-3 border-b-2 border-primary" />
|
||||||
|
Mise à jour…
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</CardTitle>
|
||||||
<Button variant="outline" size="sm">
|
<Button variant="outline" size="sm">
|
||||||
<Download className="w-4 h-4 mr-2" />
|
<Download className="w-4 h-4 mr-2" />
|
||||||
Exporter PDF
|
Exporter PDF
|
||||||
@@ -934,14 +969,17 @@ export default function DlcPage() {
|
|||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{productsLoading ? (
|
{productsLoading || (isPlaceholderData && totalItems === 0) ? (
|
||||||
<div className="flex justify-center items-center h-32">Chargement des produits...</div>
|
<div className="flex justify-center items-center h-32">Chargement des produits...</div>
|
||||||
) : totalItems === 0 ? (
|
) : totalItems === 0 ? (
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
Aucun produit DLC trouvé
|
Aucun produit DLC trouvé
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div
|
||||||
|
className={isPlaceholderData ? "opacity-60 pointer-events-none transition-opacity" : ""}
|
||||||
|
aria-busy={isPlaceholderData}
|
||||||
|
>
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
@@ -992,7 +1030,7 @@ export default function DlcPage() {
|
|||||||
variant="default"
|
variant="default"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleValidate(product.id)}
|
onClick={() => handleValidate(product.id)}
|
||||||
disabled={validateMutation.isPending}
|
disabled={isRowActionPending("validate", product.id)}
|
||||||
className="bg-green-600 hover:bg-green-700"
|
className="bg-green-600 hover:bg-green-700"
|
||||||
data-testid={`button-validate-${product.id}`}
|
data-testid={`button-validate-${product.id}`}
|
||||||
title="Valider définitivement ce produit"
|
title="Valider définitivement ce produit"
|
||||||
@@ -1010,7 +1048,7 @@ export default function DlcPage() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleMarkProcessed(product.id)}
|
onClick={() => handleMarkProcessed(product.id)}
|
||||||
disabled={markProcessedMutation.isPending}
|
disabled={isRowActionPending("mark-processed", product.id)}
|
||||||
className="border-blue-300 text-blue-700 hover:bg-blue-50"
|
className="border-blue-300 text-blue-700 hover:bg-blue-50"
|
||||||
data-testid={`button-mark-processed-${product.id}`}
|
data-testid={`button-mark-processed-${product.id}`}
|
||||||
>
|
>
|
||||||
@@ -1036,7 +1074,7 @@ export default function DlcPage() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleUnmarkProcessed(product.id)}
|
onClick={() => handleUnmarkProcessed(product.id)}
|
||||||
disabled={unmarkProcessedMutation.isPending}
|
disabled={isRowActionPending("unmark-processed", product.id)}
|
||||||
className="border-orange-300 text-orange-700 hover:bg-orange-50"
|
className="border-orange-300 text-orange-700 hover:bg-orange-50"
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
@@ -1062,7 +1100,7 @@ export default function DlcPage() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleMarkStockEpuise(product.id)}
|
onClick={() => handleMarkStockEpuise(product.id)}
|
||||||
disabled={markStockEpuiseMutation.isPending}
|
disabled={isRowActionPending("stock-epuise", product.id)}
|
||||||
className="border-yellow-300 text-yellow-700 hover:bg-yellow-50"
|
className="border-yellow-300 text-yellow-700 hover:bg-yellow-50"
|
||||||
>
|
>
|
||||||
<PackageX className="w-4 h-4" />
|
<PackageX className="w-4 h-4" />
|
||||||
@@ -1087,7 +1125,7 @@ export default function DlcPage() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleRestoreStock(product.id)}
|
onClick={() => handleRestoreStock(product.id)}
|
||||||
disabled={restoreStockMutation.isPending}
|
disabled={isRowActionPending("restore-stock", product.id)}
|
||||||
className="border-blue-300 text-blue-700 hover:bg-blue-50"
|
className="border-blue-300 text-blue-700 hover:bg-blue-50"
|
||||||
>
|
>
|
||||||
<RotateCcw className="w-4 h-4" />
|
<RotateCcw className="w-4 h-4" />
|
||||||
@@ -1106,7 +1144,7 @@ export default function DlcPage() {
|
|||||||
variant="destructive"
|
variant="destructive"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleDelete(product.id)}
|
onClick={() => handleDelete(product.id)}
|
||||||
disabled={deleteMutation.isPending}
|
disabled={isRowActionPending("delete", product.id)}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -0,0 +1,291 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { apiRequest } from "@/lib/queryClient";
|
||||||
|
import { AlertTriangle, Check, Copy, KeyRound, Plug, Plus, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
|
interface ApiKey {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
keyPrefix: string;
|
||||||
|
createdAt: string | null;
|
||||||
|
lastUsedAt: string | null;
|
||||||
|
revokedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApiKeysResponse {
|
||||||
|
basePath: string;
|
||||||
|
envKeyCount: number;
|
||||||
|
keys: ApiKey[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const QUERY_KEY = ["/api/external-api/keys"];
|
||||||
|
|
||||||
|
function formatDateTime(value: string | null): string {
|
||||||
|
if (!value) return "—";
|
||||||
|
return new Date(value).toLocaleString("fr-FR", { dateStyle: "short", timeStyle: "short" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message d'erreur lisible depuis « 400: {"error":"..."} » renvoyé par apiRequest
|
||||||
|
function errorMessage(error: unknown): string {
|
||||||
|
const raw = error instanceof Error ? error.message : String(error);
|
||||||
|
const json = raw.slice(raw.indexOf(":") + 1).trim();
|
||||||
|
try {
|
||||||
|
return JSON.parse(json).error || raw;
|
||||||
|
} catch {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function CopyButton({ value, label }: { value: string; label: string }) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const copy = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(value);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
} catch {
|
||||||
|
toast({ title: "Copie impossible", description: "Sélectionnez le texte et copiez-le à la main.", variant: "destructive" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={copy} aria-label={label} className="shrink-0">
|
||||||
|
{copied ? <Check className="w-4 h-4 text-green-600" /> : <Copy className="w-4 h-4" />}
|
||||||
|
<span className="ml-1.5">{copied ? "Copié" : "Copier"}</span>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ExternalApiSettings() {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [newKeyName, setNewKeyName] = useState("");
|
||||||
|
const [createdKey, setCreatedKey] = useState<{ name: string; key: string } | null>(null);
|
||||||
|
const [keyToRevoke, setKeyToRevoke] = useState<ApiKey | null>(null);
|
||||||
|
|
||||||
|
const { data, isLoading, error } = useQuery<ApiKeysResponse>({ queryKey: QUERY_KEY });
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (name: string) => apiRequest("/api/external-api/keys", "POST", { name }),
|
||||||
|
onSuccess: (result: { key: string; apiKey: ApiKey }) => {
|
||||||
|
setCreatedKey({ name: result.apiKey.name, key: result.key });
|
||||||
|
setNewKeyName("");
|
||||||
|
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
|
||||||
|
},
|
||||||
|
onError: (err) => toast({ title: "Création impossible", description: errorMessage(err), variant: "destructive" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const revokeMutation = useMutation({
|
||||||
|
mutationFn: (id: number) => apiRequest(`/api/external-api/keys/${id}`, "DELETE"),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({ title: "Clé révoquée", description: "Les outils qui l'utilisaient n'ont plus accès à l'API." });
|
||||||
|
setKeyToRevoke(null);
|
||||||
|
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
|
||||||
|
},
|
||||||
|
onError: (err) => toast({ title: "Révocation impossible", description: errorMessage(err), variant: "destructive" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const apiUrl = `${window.location.origin}${data?.basePath ?? "/api/ext/v1"}`;
|
||||||
|
const activeKeys = data?.keys.filter((k) => !k.revokedAt) ?? [];
|
||||||
|
const revokedKeys = data?.keys.filter((k) => k.revokedAt) ?? [];
|
||||||
|
const apiEnabled = activeKeys.length > 0 || (data?.envKeyCount ?? 0) > 0;
|
||||||
|
|
||||||
|
const handleCreate = (event: React.FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const name = newKeyName.trim();
|
||||||
|
if (name) createMutation.mutate(name);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full overflow-y-auto space-y-6">
|
||||||
|
{/* Présentation et adresse */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Plug className="w-5 h-5 text-blue-600" />
|
||||||
|
API externe de rapprochement
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Permet à un outil externe (n8n, comptabilité, script…) de lire les fournisseurs et les numéros de BL
|
||||||
|
des livraisons, puis d'écrire le numéro de facture, les montants et l'échéance.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-sm font-medium text-gray-700">État :</span>
|
||||||
|
{isLoading ? (
|
||||||
|
<span className="text-sm text-gray-600">Chargement…</span>
|
||||||
|
) : apiEnabled ? (
|
||||||
|
<Badge className="bg-green-100 text-green-800 hover:bg-green-100">Active</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline" className="border-orange-300 text-orange-800">Désactivée : aucune clé active</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="api-url">Adresse de l'API</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input id="api-url" value={apiUrl} readOnly className="font-mono text-sm" />
|
||||||
|
<CopyButton value={apiUrl} label="Copier l'adresse de l'API" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
La clé s'envoie dans l'en-tête <code className="rounded bg-gray-100 px-1">X-API-Key</code>{" "}
|
||||||
|
(ou <code className="rounded bg-gray-100 px-1">Authorization: Bearer …</code>).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg bg-gray-50 border border-gray-200 p-3 text-sm text-gray-700 space-y-1">
|
||||||
|
<p className="font-medium text-gray-900">Principales routes</p>
|
||||||
|
<ul className="space-y-0.5 font-mono text-xs sm:text-sm">
|
||||||
|
<li>GET /stores — magasins</li>
|
||||||
|
<li>GET /suppliers — fournisseurs</li>
|
||||||
|
<li>GET /deliveries?supplierId=…&hasInvoice=false — livraisons et n° de BL</li>
|
||||||
|
<li>PATCH /deliveries/:id — n° de facture, montants HT/TTC, échéance</li>
|
||||||
|
</ul>
|
||||||
|
<p className="pt-1">Documentation complète : <code className="rounded bg-white px-1">docs/API-RAPPROCHEMENT.md</code></p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Clés */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<KeyRound className="w-5 h-5 text-blue-600" />
|
||||||
|
Clés d'API
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Créez une clé par outil, pour pouvoir en révoquer une sans couper les autres.
|
||||||
|
La clé n'est affichée qu'une seule fois, à sa création.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-5">
|
||||||
|
<form onSubmit={handleCreate} className="flex flex-col sm:flex-row gap-2 sm:items-end">
|
||||||
|
<div className="flex-1 space-y-1.5">
|
||||||
|
<Label htmlFor="api-key-name">Nom de la nouvelle clé</Label>
|
||||||
|
<Input
|
||||||
|
id="api-key-name"
|
||||||
|
value={newKeyName}
|
||||||
|
onChange={(e) => setNewKeyName(e.target.value)}
|
||||||
|
placeholder="Ex. : n8n comptabilité"
|
||||||
|
maxLength={100}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" disabled={!newKeyName.trim() || createMutation.isPending}>
|
||||||
|
<Plus className="w-4 h-4 mr-1.5" />
|
||||||
|
{createMutation.isPending ? "Création…" : "Créer une clé"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<p className="text-sm text-red-700">Impossible de charger les clés : {errorMessage(error)}</p>
|
||||||
|
) : isLoading ? (
|
||||||
|
<p className="text-sm text-gray-600">Chargement…</p>
|
||||||
|
) : activeKeys.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-600">Aucune clé active.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-gray-200 rounded-lg border border-gray-200">
|
||||||
|
{activeKeys.map((apiKey) => (
|
||||||
|
<li key={apiKey.id} className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4 p-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="font-medium text-gray-900 truncate">{apiKey.name}</p>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
<code className="rounded bg-gray-100 px-1">{apiKey.keyPrefix}…</code>
|
||||||
|
{" · "}créée le {formatDateTime(apiKey.createdAt)}
|
||||||
|
{" · "}
|
||||||
|
{apiKey.lastUsedAt ? `dernière utilisation le ${formatDateTime(apiKey.lastUsedAt)}` : "jamais utilisée"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="self-start sm:self-auto text-red-700 border-red-200 hover:bg-red-50"
|
||||||
|
onClick={() => setKeyToRevoke(apiKey)}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 mr-1.5" />
|
||||||
|
Révoquer
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(data?.envKeyCount ?? 0) > 0 && (
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
{data!.envKeyCount === 1 ? "1 clé est aussi définie" : `${data!.envKeyCount} clés sont aussi définies`} dans
|
||||||
|
la variable d'environnement <code className="rounded bg-gray-100 px-1">EXTERNAL_API_KEYS</code> du serveur
|
||||||
|
(non modifiables ici).
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{revokedKeys.length > 0 && (
|
||||||
|
<details className="text-sm text-gray-600">
|
||||||
|
<summary className="cursor-pointer">Clés révoquées ({revokedKeys.length})</summary>
|
||||||
|
<ul className="mt-2 space-y-1">
|
||||||
|
{revokedKeys.map((apiKey) => (
|
||||||
|
<li key={apiKey.id}>
|
||||||
|
{apiKey.name} · <code className="rounded bg-gray-100 px-1">{apiKey.keyPrefix}…</code> · révoquée le {formatDateTime(apiKey.revokedAt)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Clé créée : affichée une seule fois */}
|
||||||
|
<Dialog open={!!createdKey} onOpenChange={(open) => { if (!open) setCreatedKey(null); }}>
|
||||||
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Clé « {createdKey?.name} » créée</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Copiez-la maintenant et collez-la dans votre outil : elle ne sera plus jamais affichée.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input value={createdKey?.key ?? ""} readOnly className="font-mono text-sm" onFocus={(e) => e.currentTarget.select()} aria-label="Clé d'API" />
|
||||||
|
{createdKey && <CopyButton value={createdKey.key} label="Copier la clé d'API" />}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-2 rounded-lg bg-amber-50 border border-amber-200 p-3 text-sm text-amber-900">
|
||||||
|
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" />
|
||||||
|
<p>Cette clé donne accès aux livraisons de tous les magasins. Ne la partagez pas ; en cas de doute, révoquez-la.</p>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button onClick={() => setCreatedKey(null)}>J'ai copié la clé</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Confirmation de révocation */}
|
||||||
|
<Dialog open={!!keyToRevoke} onOpenChange={(open) => { if (!open) setKeyToRevoke(null); }}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Révoquer la clé « {keyToRevoke?.name} » ?</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Les outils qui l'utilisent perdront immédiatement l'accès à l'API. Cette action est définitive.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter className="gap-2">
|
||||||
|
<Button variant="outline" onClick={() => setKeyToRevoke(null)}>Annuler</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
disabled={revokeMutation.isPending}
|
||||||
|
onClick={() => keyToRevoke && revokeMutation.mutate(keyToRevoke.id)}
|
||||||
|
>
|
||||||
|
{revokeMutation.isPending ? "Révocation…" : "Révoquer"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -81,8 +81,13 @@ export default function Groups() {
|
|||||||
queryKey: ['/api/groups'],
|
queryKey: ['/api/groups'],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Configurations NocoDB (liste déroulante de la fenêtre magasin) : réservées à
|
||||||
|
// l'admin, le serveur répond 403 aux autres rôles. Les mutations de la page
|
||||||
|
// NocoDB invalident déjà cette clé.
|
||||||
const { data: rawNocodbConfigs = [] } = useQuery<NocodbConfig[]>({
|
const { data: rawNocodbConfigs = [] } = useQuery<NocodbConfig[]>({
|
||||||
queryKey: ['/api/nocodb-config'],
|
queryKey: ['/api/nocodb-config'],
|
||||||
|
enabled: user?.role === 'admin',
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Protection renforcée contre les erreurs TypeError
|
// Protection renforcée contre les erreurs TypeError
|
||||||
@@ -96,10 +101,7 @@ export default function Groups() {
|
|||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: async (data: any) => {
|
mutationFn: async (data: any) => {
|
||||||
console.log('🏪 Frontend: Creating group with data:', data);
|
return await apiRequest("/api/groups", "POST", data);
|
||||||
const result = await apiRequest("/api/groups", "POST", data);
|
|
||||||
console.log('🏪 Frontend: Group creation result:', result);
|
|
||||||
return result;
|
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast({
|
toast({
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export default function NocoDBConfig() {
|
|||||||
const [showTokens, setShowTokens] = useState<{ [key: number]: boolean }>({});
|
const [showTokens, setShowTokens] = useState<{ [key: number]: boolean }>({});
|
||||||
|
|
||||||
// Queries avec protection complète
|
// Queries avec protection complète
|
||||||
const { data: rawConfigs, isLoading, error } = useQuery({
|
const { data: rawConfigs, isLoading } = useQuery({
|
||||||
queryKey: ['/api/nocodb-config'],
|
queryKey: ['/api/nocodb-config'],
|
||||||
enabled: user?.role === 'admin',
|
enabled: user?.role === 'admin',
|
||||||
});
|
});
|
||||||
@@ -62,21 +62,6 @@ export default function NocoDBConfig() {
|
|||||||
const configs = rawConfigs || [];
|
const configs = rawConfigs || [];
|
||||||
const safeConfigs = Array.isArray(configs) ? configs : [];
|
const safeConfigs = Array.isArray(configs) ? configs : [];
|
||||||
|
|
||||||
// Log pour debug production avec plus de détails
|
|
||||||
console.log('🔍 NocoDBConfig Debug:', {
|
|
||||||
rawConfigs,
|
|
||||||
rawConfigsType: typeof rawConfigs,
|
|
||||||
configs,
|
|
||||||
configsType: typeof configs,
|
|
||||||
isArray: Array.isArray(configs),
|
|
||||||
safeConfigs,
|
|
||||||
safeConfigsType: typeof safeConfigs,
|
|
||||||
length: safeConfigs.length,
|
|
||||||
error,
|
|
||||||
userRole: user?.role,
|
|
||||||
environment: window.location.hostname
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mutations
|
// Mutations
|
||||||
const createConfigMutation = useMutation({
|
const createConfigMutation = useMutation({
|
||||||
mutationFn: (data: NocodbConfigForm) =>
|
mutationFn: (data: NocodbConfigForm) =>
|
||||||
|
|||||||
+20
-51
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -30,6 +30,9 @@ import OrderDetailModal from "@/components/modals/OrderDetailModal";
|
|||||||
import ConfirmDeleteModal from "@/components/modals/ConfirmDeleteModal";
|
import ConfirmDeleteModal from "@/components/modals/ConfirmDeleteModal";
|
||||||
import type { OrderWithRelations } from "@shared/schema";
|
import type { OrderWithRelations } from "@shared/schema";
|
||||||
|
|
||||||
|
// Référence stable pendant le chargement (pour la mémoïsation du filtrage)
|
||||||
|
const NO_ORDERS: OrderWithRelations[] = [];
|
||||||
|
|
||||||
export default function Orders() {
|
export default function Orders() {
|
||||||
const { user } = useAuthUnified();
|
const { user } = useAuthUnified();
|
||||||
const { selectedStoreId } = useStore();
|
const { selectedStoreId } = useStore();
|
||||||
@@ -73,7 +76,7 @@ export default function Orders() {
|
|||||||
const ordersUrl = `/api/orders${selectedStoreId ? `?storeId=${selectedStoreId}` : ''}`;
|
const ordersUrl = `/api/orders${selectedStoreId ? `?storeId=${selectedStoreId}` : ''}`;
|
||||||
|
|
||||||
|
|
||||||
const { data: ordersData = [], isLoading } = useQuery<OrderWithRelations[]>({
|
const { data: ordersData = NO_ORDERS, isLoading } = useQuery<OrderWithRelations[]>({
|
||||||
queryKey: [ordersUrl, selectedStoreId],
|
queryKey: [ordersUrl, selectedStoreId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const response = await fetch(ordersUrl, { credentials: 'include' });
|
const response = await fetch(ordersUrl, { credentials: 'include' });
|
||||||
@@ -81,69 +84,32 @@ export default function Orders() {
|
|||||||
throw new Error('Failed to fetch orders');
|
throw new Error('Failed to fetch orders');
|
||||||
}
|
}
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
console.log('📦 Orders received:', Array.isArray(data) ? data.length : 'NOT_ARRAY', 'items', data.slice(0, 2));
|
|
||||||
console.log('📦 Sample order data:', data[0]);
|
|
||||||
return Array.isArray(data) ? data : [];
|
return Array.isArray(data) ? data : [];
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Production Bug Fix: Ensure array safety for all data operations
|
// Production Bug Fix: Ensure array safety for all data operations
|
||||||
const orders = Array.isArray(ordersData) ? ordersData : [];
|
const orders = Array.isArray(ordersData) ? ordersData : NO_ORDERS;
|
||||||
|
|
||||||
console.log('📦 Orders Debug:', {
|
|
||||||
isLoading,
|
|
||||||
ordersCount: orders?.length,
|
|
||||||
orders: orders?.slice(0, 2),
|
|
||||||
selectedStoreId,
|
|
||||||
ordersUrl
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: groupsData = [] } = useQuery({
|
|
||||||
queryKey: ['/api/groups'],
|
|
||||||
});
|
|
||||||
|
|
||||||
const groups = Array.isArray(groupsData) ? groupsData : [];
|
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: async (id: number) => {
|
mutationFn: async (id: number) => {
|
||||||
console.log('🗑️ Deleting order:', id);
|
|
||||||
await apiRequest(`/api/orders/${id}`, "DELETE");
|
await apiRequest(`/api/orders/${id}`, "DELETE");
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
console.log('✅ Order deleted successfully, invalidating cache...');
|
|
||||||
toast({
|
toast({
|
||||||
title: "Succès",
|
title: "Succès",
|
||||||
description: "Commande supprimée avec succès",
|
description: "Commande supprimée avec succès",
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('🗑️ Order deleted, clearing ALL cache to avoid inconsistencies:', {
|
// Invalidation ciblée sans clear() pour préserver le contexte : les requêtes
|
||||||
ordersUrl,
|
// affichées sont rechargées tout de suite, les autres (mois du calendrier
|
||||||
selectedStoreId
|
// déjà visités, autres pages) seulement quand elles seront réaffichées
|
||||||
});
|
|
||||||
|
|
||||||
// Sauvegarder le selectedStoreId avant le nettoyage
|
|
||||||
if (selectedStoreId) {
|
|
||||||
localStorage.setItem('selectedStoreId', selectedStoreId.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
// SOLUTION HYBRIDE : Invalidation sélective pour éviter perte storeId
|
|
||||||
console.log('🧹 Using selective invalidation to preserve storeId context...');
|
|
||||||
|
|
||||||
// Invalidation ciblée sans clear() pour préserver le contexte
|
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
predicate: (query) => {
|
predicate: (query) => {
|
||||||
const key = query.queryKey[0]?.toString() || '';
|
const key = query.queryKey[0]?.toString() || '';
|
||||||
return key.includes('/api/orders') || key.includes('/api/deliveries');
|
return key.includes('/api/orders') || key.includes('/api/deliveries');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Force refetch pour garantir synchronisation immédiate
|
|
||||||
queryClient.refetchQueries({
|
|
||||||
predicate: (query) => {
|
|
||||||
const key = query.queryKey[0]?.toString() || '';
|
|
||||||
return key.includes('/api/orders') || key.includes('/api/deliveries');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
if (isUnauthorizedError(error)) {
|
if (isUnauthorizedError(error)) {
|
||||||
@@ -165,16 +131,19 @@ export default function Orders() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const filteredOrders = Array.isArray(orders) ? orders.filter(order => {
|
// Filtrage recalculé seulement quand la liste, la recherche ou le statut changent
|
||||||
console.log('🔍 Filtering order:', order.id, { searchTerm, statusFilter });
|
const filteredOrders = useMemo(() => {
|
||||||
const matchesSearch = order.supplier?.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
const search = searchTerm.toLowerCase();
|
||||||
order.group?.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
return orders.filter(order => {
|
||||||
order.notes?.toLowerCase().includes(searchTerm.toLowerCase());
|
const matchesSearch = order.supplier?.name.toLowerCase().includes(search) ||
|
||||||
|
order.group?.name.toLowerCase().includes(search) ||
|
||||||
|
order.notes?.toLowerCase().includes(search);
|
||||||
|
|
||||||
const matchesStatus = statusFilter === "all" || order.status === statusFilter;
|
const matchesStatus = statusFilter === "all" || order.status === statusFilter;
|
||||||
|
|
||||||
return matchesSearch && matchesStatus;
|
return matchesSearch && matchesStatus;
|
||||||
}) : [];
|
});
|
||||||
|
}, [orders, searchTerm, statusFilter]);
|
||||||
|
|
||||||
// Pagination
|
// Pagination
|
||||||
const {
|
const {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useStore } from "@/contexts/StoreContext";
|
import { useStore } from "@/contexts/StoreContext";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
@@ -53,6 +53,7 @@ interface PaymentScheduleResponse {
|
|||||||
export default function PaymentSchedulePage() {
|
export default function PaymentSchedulePage() {
|
||||||
const { selectedStoreId } = useStore();
|
const { selectedStoreId } = useStore();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const [selectedMonth, setSelectedMonth] = useState<string>(() => {
|
const [selectedMonth, setSelectedMonth] = useState<string>(() => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
return format(now, 'yyyy-MM');
|
return format(now, 'yyyy-MM');
|
||||||
@@ -75,7 +76,12 @@ export default function PaymentSchedulePage() {
|
|||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Erreur ${response.status}: ${response.statusText}`);
|
throw new Error(`Erreur ${response.status}: ${response.statusText}`);
|
||||||
}
|
}
|
||||||
return response.json();
|
const result = await response.json();
|
||||||
|
// Ce calcul peut compléter en base les échéances et montants manquants des
|
||||||
|
// livraisons : les listes de livraisons en cache sont marquées périmées
|
||||||
|
// (rechargées à leur prochain affichage, pas immédiatement)
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['/api/deliveries'], refetchType: 'none' });
|
||||||
|
return result;
|
||||||
},
|
},
|
||||||
enabled: !!selectedStoreId,
|
enabled: !!selectedStoreId,
|
||||||
});
|
});
|
||||||
@@ -251,10 +257,50 @@ export default function PaymentSchedulePage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// En-tête (titre, export, choix du mois) : conservé pendant le chargement,
|
||||||
|
// notamment au changement de magasin, au lieu d'un écran entièrement vide
|
||||||
|
const header = (
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold">Échéancier des Paiements</h1>
|
||||||
|
<p className="text-gray-600 mt-1">Gestion des échéances fournisseurs</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button
|
||||||
|
onClick={handleOpenExportModal}
|
||||||
|
variant="outline"
|
||||||
|
className="gap-2"
|
||||||
|
disabled={filteredSchedules.length === 0}
|
||||||
|
data-testid="button-export-csv"
|
||||||
|
>
|
||||||
|
<FileSpreadsheet className="h-4 w-4" />
|
||||||
|
Exporter CSV
|
||||||
|
</Button>
|
||||||
|
<div className="w-64">
|
||||||
|
<Select value={selectedMonth} onValueChange={setSelectedMonth}>
|
||||||
|
<SelectTrigger data-testid="select-month">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{availableMonths.map(month => (
|
||||||
|
<SelectItem key={month.value} value={month.value}>
|
||||||
|
{month.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex items-center justify-center">
|
<div className="p-6 space-y-6">
|
||||||
<Loader2 className="w-8 h-8 animate-spin text-blue-600" />
|
{header}
|
||||||
|
<div className="flex items-center justify-center py-16">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-blue-600" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -292,38 +338,7 @@ export default function PaymentSchedulePage() {
|
|||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-6">
|
||||||
{/* En-tête */}
|
{/* En-tête */}
|
||||||
<div className="flex justify-between items-center">
|
{header}
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold">Échéancier des Paiements</h1>
|
|
||||||
<p className="text-gray-600 mt-1">Gestion des échéances fournisseurs</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Button
|
|
||||||
onClick={handleOpenExportModal}
|
|
||||||
variant="outline"
|
|
||||||
className="gap-2"
|
|
||||||
disabled={filteredSchedules.length === 0}
|
|
||||||
data-testid="button-export-csv"
|
|
||||||
>
|
|
||||||
<FileSpreadsheet className="h-4 w-4" />
|
|
||||||
Exporter CSV
|
|
||||||
</Button>
|
|
||||||
<div className="w-64">
|
|
||||||
<Select value={selectedMonth} onValueChange={setSelectedMonth}>
|
|
||||||
<SelectTrigger data-testid="select-month">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{availableMonths.map(month => (
|
|
||||||
<SelectItem key={month.value} value={month.value}>
|
|
||||||
{month.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Statistiques du mois */}
|
{/* Statistiques du mois */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
|||||||
+457
-471
File diff suppressed because it is too large.
Load diff
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient, keepPreviousData } from "@tanstack/react-query";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -49,6 +49,10 @@ const priorityConfig = {
|
|||||||
critique: { label: "Critique", color: "bg-red-100 text-red-800" }
|
critique: { label: "Critique", color: "bg-red-100 text-red-800" }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Références stables tant que les données ne sont pas chargées (évitent de recalculer les filtres)
|
||||||
|
const NO_TICKETS: SavTicketWithRelations[] = [];
|
||||||
|
const NO_GROUPS: Group[] = [];
|
||||||
|
|
||||||
export default function SavTickets() {
|
export default function SavTickets() {
|
||||||
const { user } = useAuthUnified();
|
const { user } = useAuthUnified();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@@ -94,10 +98,15 @@ export default function SavTickets() {
|
|||||||
|
|
||||||
const ticketsUrl = `/api/sav/tickets${queryParams.toString() ? '?' + queryParams.toString() : ''}`;
|
const ticketsUrl = `/api/sav/tickets${queryParams.toString() ? '?' + queryParams.toString() : ''}`;
|
||||||
|
|
||||||
const { data: ticketsData = [], isLoading } = useQuery<SavTicketWithRelations[]>({
|
const { data: ticketsData = NO_TICKETS, isLoading, isPlaceholderData } = useQuery<SavTicketWithRelations[]>({
|
||||||
queryKey: [ticketsUrl, selectedStoreId],
|
queryKey: [ticketsUrl, selectedStoreId],
|
||||||
enabled: !!user && (user.role !== 'admin' || storeInitialized),
|
enabled: !!user && (user.role !== 'admin' || storeInitialized),
|
||||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||||
|
// Changement de filtre : les tickets précédents restent affichés (estompés)
|
||||||
|
// pendant le chargement. Jamais au changement de magasin, pour ne pas montrer
|
||||||
|
// un instant les tickets d'un autre magasin.
|
||||||
|
placeholderData: (previousData, previousQuery) =>
|
||||||
|
previousQuery?.queryKey[1] === selectedStoreId ? keepPreviousData(previousData) : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: suppliersData = [] } = useQuery<Supplier[]>({
|
const { data: suppliersData = [] } = useQuery<Supplier[]>({
|
||||||
@@ -105,7 +114,7 @@ export default function SavTickets() {
|
|||||||
staleTime: 1000 * 60 * 10, // 10 minutes
|
staleTime: 1000 * 60 * 10, // 10 minutes
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: groupsData = [] } = useQuery<Group[]>({
|
const { data: groupsData = NO_GROUPS } = useQuery<Group[]>({
|
||||||
queryKey: ['/api/groups'],
|
queryKey: ['/api/groups'],
|
||||||
staleTime: 1000 * 60 * 10, // 10 minutes
|
staleTime: 1000 * 60 * 10, // 10 minutes
|
||||||
});
|
});
|
||||||
@@ -288,30 +297,32 @@ export default function SavTickets() {
|
|||||||
const canModify = ['admin', 'directeur', 'manager'].includes(user?.role || '');
|
const canModify = ['admin', 'directeur', 'manager'].includes(user?.role || '');
|
||||||
const canDelete = ['admin', 'directeur'].includes(user?.role || '');
|
const canDelete = ['admin', 'directeur'].includes(user?.role || '');
|
||||||
|
|
||||||
// Get user's available groups for auto-assignment
|
// Get user's available groups for auto-assignment (recalculé seulement quand
|
||||||
const getUserGroups = () => {
|
// l'utilisateur ou la liste des magasins change, et non à chaque frappe)
|
||||||
|
const availableGroups = useMemo(() => {
|
||||||
if (!user || user.role === 'admin') {
|
if (!user || user.role === 'admin') {
|
||||||
console.log('🎫 [CLIENT] Admin user - can access all groups:', groupsData?.length || 0);
|
|
||||||
return groupsData; // Admin can access all groups
|
return groupsData; // Admin can access all groups
|
||||||
}
|
}
|
||||||
|
|
||||||
// For other users, get their assigned groups
|
// For other users, get their assigned groups
|
||||||
const userGroups = (user as any).userGroups || [];
|
const userGroups = (user as any).userGroups || [];
|
||||||
const filteredGroups = groupsData.filter(group =>
|
return groupsData.filter(group =>
|
||||||
userGroups.some((ug: any) => ug.groupId === group.id)
|
userGroups.some((ug: any) => ug.groupId === group.id)
|
||||||
);
|
);
|
||||||
|
}, [user, groupsData]);
|
||||||
|
|
||||||
console.log('🎫 [CLIENT] Non-admin user groups:', {
|
// Filter tickets by search term
|
||||||
userRole: user.role,
|
const filteredTickets = useMemo(() => {
|
||||||
userUsername: user.username,
|
if (!searchTerm) return ticketsData;
|
||||||
allUserGroups: userGroups,
|
const searchLower = searchTerm.toLowerCase();
|
||||||
filteredGroups: filteredGroups.map(g => ({ id: g.id, name: g.name }))
|
return ticketsData.filter(ticket => (
|
||||||
});
|
ticket.ticketNumber.toLowerCase().includes(searchLower) ||
|
||||||
|
ticket.clientName?.toLowerCase().includes(searchLower) ||
|
||||||
return filteredGroups;
|
ticket.problemDescription?.toLowerCase().includes(searchLower) ||
|
||||||
};
|
ticket.productDesignation?.toLowerCase().includes(searchLower) ||
|
||||||
|
ticket.supplier?.name.toLowerCase().includes(searchLower)
|
||||||
const availableGroups = getUserGroups();
|
));
|
||||||
|
}, [ticketsData, searchTerm]);
|
||||||
|
|
||||||
// Handle form submission
|
// Handle form submission
|
||||||
const handleCreateTicket = () => {
|
const handleCreateTicket = () => {
|
||||||
@@ -327,17 +338,9 @@ export default function SavTickets() {
|
|||||||
// Auto-assign group based on user role and available groups
|
// Auto-assign group based on user role and available groups
|
||||||
let selectedGroupId = formData.groupId ? parseInt(formData.groupId) : null;
|
let selectedGroupId = formData.groupId ? parseInt(formData.groupId) : null;
|
||||||
|
|
||||||
console.log('🎫 [CLIENT] User groups analysis:', {
|
|
||||||
userRole: user?.role,
|
|
||||||
formDataGroupId: formData.groupId,
|
|
||||||
availableGroups: availableGroups.map(g => ({ id: g.id, name: g.name })),
|
|
||||||
selectedGroupId
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!selectedGroupId && availableGroups.length > 0) {
|
if (!selectedGroupId && availableGroups.length > 0) {
|
||||||
// Auto-assign first available group
|
// Auto-assign first available group
|
||||||
selectedGroupId = availableGroups[0].id;
|
selectedGroupId = availableGroups[0].id;
|
||||||
console.log('🎫 [CLIENT] Auto-assigned group:', selectedGroupId, availableGroups[0].name);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ticketData: InsertSavTicket = {
|
const ticketData: InsertSavTicket = {
|
||||||
@@ -355,14 +358,6 @@ export default function SavTickets() {
|
|||||||
clientPhone: formData.clientPhone || undefined,
|
clientPhone: formData.clientPhone || undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log('🎫 [CLIENT] Creating ticket with data:', {
|
|
||||||
...ticketData,
|
|
||||||
userInfo: {
|
|
||||||
role: user?.role,
|
|
||||||
username: user?.username,
|
|
||||||
availableGroups: availableGroups.length
|
|
||||||
}
|
|
||||||
});
|
|
||||||
createTicketMutation.mutate(ticketData);
|
createTicketMutation.mutate(ticketData);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -372,13 +367,6 @@ export default function SavTickets() {
|
|||||||
setShowDetailModal(true);
|
setShowDetailModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Query pour récupérer les détails complets du ticket avec historique
|
|
||||||
const { data: ticketDetails } = useQuery({
|
|
||||||
queryKey: [`/api/sav/tickets/${selectedTicket?.id}`],
|
|
||||||
enabled: !!selectedTicket && showDetailModal,
|
|
||||||
staleTime: 0, // Toujours récupérer les derniers commentaires
|
|
||||||
});
|
|
||||||
|
|
||||||
// Function to check if ticket has recent comments (last 24h)
|
// Function to check if ticket has recent comments (last 24h)
|
||||||
const hasRecentComments = (ticket: SavTicketWithRelations) => {
|
const hasRecentComments = (ticket: SavTicketWithRelations) => {
|
||||||
if (!ticket.history || ticket.history.length === 0) return false;
|
if (!ticket.history || ticket.history.length === 0) return false;
|
||||||
@@ -441,19 +429,6 @@ export default function SavTickets() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter tickets by search term
|
|
||||||
const filteredTickets = ticketsData.filter(ticket => {
|
|
||||||
if (!searchTerm) return true;
|
|
||||||
const searchLower = searchTerm.toLowerCase();
|
|
||||||
return (
|
|
||||||
ticket.ticketNumber.toLowerCase().includes(searchLower) ||
|
|
||||||
ticket.clientName?.toLowerCase().includes(searchLower) ||
|
|
||||||
ticket.problemDescription?.toLowerCase().includes(searchLower) ||
|
|
||||||
ticket.productDesignation?.toLowerCase().includes(searchLower) ||
|
|
||||||
ticket.supplier?.name.toLowerCase().includes(searchLower)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-3 sm:p-6 space-y-4 sm:space-y-6">
|
<div className="p-3 sm:p-6 space-y-4 sm:space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -823,7 +798,11 @@ export default function SavTickets() {
|
|||||||
Liste des tickets de service après-vente
|
Liste des tickets de service après-vente
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-0">
|
{/* Estompé pendant le chargement d'un nouveau filtre (anciens résultats affichés) */}
|
||||||
|
<CardContent
|
||||||
|
className={`p-0 ${isPlaceholderData ? 'opacity-60 transition-opacity' : ''}`}
|
||||||
|
aria-busy={isPlaceholderData}
|
||||||
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="p-8 text-center">
|
<div className="p-8 text-center">
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -31,6 +31,10 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { EntityActivityStats, Supplier } from "@shared/schema";
|
import type { EntityActivityStats, Supplier } from "@shared/schema";
|
||||||
|
|
||||||
|
// Références stables tant que les données ne sont pas chargées (dépendances des useMemo)
|
||||||
|
const NO_SUPPLIERS: Supplier[] = [];
|
||||||
|
const NO_STATS: EntityActivityStats[] = [];
|
||||||
|
|
||||||
export default function Suppliers() {
|
export default function Suppliers() {
|
||||||
const { user } = useAuthUnified();
|
const { user } = useAuthUnified();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@@ -52,22 +56,19 @@ export default function Suppliers() {
|
|||||||
paymentMethod: "",
|
paymentMethod: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: suppliers = [], isLoading } = useQuery<Supplier[]>({
|
const { data: suppliers = NO_SUPPLIERS, isLoading } = useQuery<Supplier[]>({
|
||||||
queryKey: ['/api/suppliers'],
|
queryKey: ['/api/suppliers'],
|
||||||
});
|
});
|
||||||
|
|
||||||
// Comptages agrégés en base : la page n'affiche que des totaux, inutile de
|
// Comptages agrégés en base : la page n'affiche que des totaux, inutile de
|
||||||
// rapatrier l'historique complet des commandes et des livraisons.
|
// rapatrier l'historique complet des commandes et des livraisons.
|
||||||
const { data: supplierStats = [] } = useQuery<EntityActivityStats[]>({
|
const { data: supplierStats = NO_STATS } = useQuery<EntityActivityStats[]>({
|
||||||
queryKey: ['/api/stats/by-supplier'],
|
queryKey: ['/api/stats/by-supplier'],
|
||||||
});
|
});
|
||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: async (data: any) => {
|
mutationFn: async (data: any) => {
|
||||||
console.log('🚚 Frontend: Creating supplier with data:', data);
|
return await apiRequest("/api/suppliers", "POST", data);
|
||||||
const result = await apiRequest("/api/suppliers", "POST", data);
|
|
||||||
console.log('🚚 Frontend: Supplier creation result:', result);
|
|
||||||
return result;
|
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast({
|
toast({
|
||||||
@@ -100,14 +101,10 @@ export default function Suppliers() {
|
|||||||
|
|
||||||
const updateMutation = useMutation({
|
const updateMutation = useMutation({
|
||||||
mutationFn: async (data: any) => {
|
mutationFn: async (data: any) => {
|
||||||
console.log('🔧 Frontend: Updating supplier with data:', data);
|
return await apiRequest(`/api/suppliers/${selectedSupplier?.id}`, "PUT", data);
|
||||||
const result = await apiRequest(`/api/suppliers/${selectedSupplier?.id}`, "PUT", data);
|
|
||||||
console.log('🔧 Frontend: Supplier update result:', result);
|
|
||||||
return result;
|
|
||||||
},
|
},
|
||||||
onMutate: async (newData) => {
|
onMutate: async (newData) => {
|
||||||
// Mise à jour optimiste - mettre à jour l'interface immédiatement
|
// Mise à jour optimiste - mettre à jour l'interface immédiatement
|
||||||
console.log('🚀 Frontend: Optimistic update starting...');
|
|
||||||
await queryClient.cancelQueries({ queryKey: ['/api/suppliers'] });
|
await queryClient.cancelQueries({ queryKey: ['/api/suppliers'] });
|
||||||
|
|
||||||
const previousSuppliers = queryClient.getQueryData(['/api/suppliers']);
|
const previousSuppliers = queryClient.getQueryData(['/api/suppliers']);
|
||||||
@@ -119,14 +116,12 @@ export default function Suppliers() {
|
|||||||
: supplier
|
: supplier
|
||||||
);
|
);
|
||||||
queryClient.setQueryData(['/api/suppliers'], updatedSuppliers);
|
queryClient.setQueryData(['/api/suppliers'], updatedSuppliers);
|
||||||
console.log('✅ Frontend: Optimistic update applied');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { previousSuppliers };
|
return { previousSuppliers };
|
||||||
},
|
},
|
||||||
onError: (error, newData, context) => {
|
onError: (error, newData, context) => {
|
||||||
// Rollback en cas d'erreur
|
// Rollback en cas d'erreur
|
||||||
console.log('⚠️ Frontend: Rolling back optimistic update due to error');
|
|
||||||
if (context?.previousSuppliers) {
|
if (context?.previousSuppliers) {
|
||||||
queryClient.setQueryData(['/api/suppliers'], context.previousSuppliers);
|
queryClient.setQueryData(['/api/suppliers'], context.previousSuppliers);
|
||||||
}
|
}
|
||||||
@@ -153,13 +148,15 @@ export default function Suppliers() {
|
|||||||
title: "Succès",
|
title: "Succès",
|
||||||
description: "Fournisseur modifié avec succès",
|
description: "Fournisseur modifié avec succès",
|
||||||
});
|
});
|
||||||
queryClient.invalidateQueries({ queryKey: ['/api/suppliers'] });
|
|
||||||
setShowEditModal(false);
|
setShowEditModal(false);
|
||||||
setSelectedSupplier(null);
|
setSelectedSupplier(null);
|
||||||
resetForm();
|
resetForm();
|
||||||
},
|
},
|
||||||
|
// Une seule invalidation, après succès comme après échec
|
||||||
onSettled: () => {
|
onSettled: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['/api/suppliers'] });
|
queryClient.invalidateQueries({ queryKey: ['/api/suppliers'] });
|
||||||
|
// Le mode de paiement est affiché dans l'échéancier (cache de 5 min)
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['/api/payment-schedule'] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -273,14 +270,20 @@ export default function Suppliers() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredSuppliers = suppliers.filter(supplier =>
|
// Filtre recalculé seulement quand la liste ou la recherche change, terme normalisé une seule fois
|
||||||
supplier.name.toLowerCase().includes(searchTerm.toLowerCase())
|
const filteredSuppliers = useMemo(() => {
|
||||||
|
const term = searchTerm.toLowerCase();
|
||||||
|
return suppliers.filter(supplier => supplier.name.toLowerCase().includes(term));
|
||||||
|
}, [suppliers, searchTerm]);
|
||||||
|
|
||||||
|
// Statistiques indexées par fournisseur (au lieu d'une recherche par carte)
|
||||||
|
const statsBySupplierId = useMemo(
|
||||||
|
() => new Map(Array.isArray(supplierStats) ? supplierStats.map(s => [s.id, s]) : []),
|
||||||
|
[supplierStats]
|
||||||
);
|
);
|
||||||
|
|
||||||
const getSupplierStats = (supplierId: number) => {
|
const getSupplierStats = (supplierId: number) => {
|
||||||
const stats = Array.isArray(supplierStats)
|
const stats = statsBySupplierId.get(supplierId);
|
||||||
? supplierStats.find(s => s.id === supplierId)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
orders: stats?.orders ?? 0,
|
orders: stats?.orders ?? 0,
|
||||||
|
|||||||
+208
-187
@@ -1,15 +1,15 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useMemo, useDeferredValue } from "react";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient, useMutation, keepPreviousData } from "@tanstack/react-query";
|
||||||
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
||||||
import { useStore } from "@/contexts/StoreContext";
|
import { useStore } from "@/contexts/StoreContext";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { useScreenSize, type ScreenBreakpoints } from "@/hooks/use-screen-size";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Pagination, usePagination } from "@/components/ui/pagination";
|
import { Pagination, usePagination } from "@/components/ui/pagination";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import {
|
import {
|
||||||
ListTodo,
|
ListTodo,
|
||||||
@@ -42,9 +42,17 @@ type TaskWithRelations = Task & {
|
|||||||
isFutureTask?: boolean; // Pour les tâches futures (admin/directeur uniquement)
|
isFutureTask?: boolean; // Pour les tâches futures (admin/directeur uniquement)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Référence stable tant que la liste n'est pas chargée (évite de recalculer les filtres)
|
||||||
|
const NO_TASKS: TaskWithRelations[] = [];
|
||||||
|
|
||||||
|
// Seuils alignés sur le point de rupture « lg » de Tailwind (1024 px) qui séparait
|
||||||
|
// les deux mises en page jusque-là masquées en CSS : bureau à partir de 1024 px
|
||||||
|
const TASKS_LAYOUT_BREAKPOINTS: ScreenBreakpoints = { mobile: 768, tablet: 1023, desktop: 1280 };
|
||||||
|
|
||||||
// Composant TaskForm inline - Version production ultra-simple
|
// Composant TaskForm inline - Version production ultra-simple
|
||||||
function TaskFormInline({ task, onClose, selectedStoreId, user }: any) {
|
function TaskFormInline({ task, onClose, selectedStoreId, user }: any) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
@@ -91,22 +99,12 @@ function TaskFormInline({ task, onClose, selectedStoreId, user }: any) {
|
|||||||
(taskData as any).createdBy = user?.username || 'admin';
|
(taskData as any).createdBy = user?.username || 'admin';
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('📤 Tasks.tsx - Sending request:', {
|
|
||||||
url,
|
|
||||||
method,
|
|
||||||
taskData,
|
|
||||||
selectedStoreId,
|
|
||||||
finalGroupId: (taskData as any).groupId
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method,
|
method,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(taskData),
|
body: JSON.stringify(taskData),
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('📥 Response:', { status: response.status, ok: response.ok });
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorText = await response.text();
|
const errorText = await response.text();
|
||||||
console.error('❌ API Error:', { status: response.status, error: errorText });
|
console.error('❌ API Error:', { status: response.status, error: errorText });
|
||||||
@@ -117,7 +115,10 @@ function TaskFormInline({ task, onClose, selectedStoreId, user }: any) {
|
|||||||
title: "Succès",
|
title: "Succès",
|
||||||
description: task ? "Tâche modifiée avec succès" : "Tâche créée avec succès",
|
description: task ? "Tâche modifiée avec succès" : "Tâche créée avec succès",
|
||||||
});
|
});
|
||||||
window.location.reload();
|
// Rechargement ciblé de la liste des tâches (plus de rechargement complet de
|
||||||
|
// la page) : filtres, recherche et pagination sont conservés
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["/api/tasks"] });
|
||||||
|
onClose();
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur:', error);
|
console.error('Erreur:', error);
|
||||||
@@ -277,35 +278,11 @@ function TaskFormInline({ task, onClose, selectedStoreId, user }: any) {
|
|||||||
|
|
||||||
export default function Tasks() {
|
export default function Tasks() {
|
||||||
const { user } = useAuthUnified();
|
const { user } = useAuthUnified();
|
||||||
const { selectedStoreId, storeInitialized } = useStore();
|
const { selectedStoreId } = useStore();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
// Une seule des deux mises en page (tablette ou bureau) est montée
|
||||||
// Debug enablement condition for tasks query
|
const { isDesktop: isLargeScreen } = useScreenSize(TASKS_LAYOUT_BREAKPOINTS);
|
||||||
const isQueryEnabled = !!user && (user.role === 'admin' || (user.role === 'directeur' || user.role === 'manager' ? !!selectedStoreId : true));
|
|
||||||
console.log('🔍 TASK QUERY ENABLEMENT DEBUG:', {
|
|
||||||
hasUser: !!user,
|
|
||||||
userRole: user?.role,
|
|
||||||
selectedStoreId,
|
|
||||||
isAdmin: user?.role === 'admin',
|
|
||||||
isDirecteurOrManager: user?.role === 'directeur' || user?.role === 'manager',
|
|
||||||
hasSelectedStore: !!selectedStoreId,
|
|
||||||
finalEnabled: isQueryEnabled,
|
|
||||||
storeInitialized,
|
|
||||||
timestamp: new Date().toISOString()
|
|
||||||
});
|
|
||||||
|
|
||||||
// Force refresh when selectedStoreId changes for directeur/manager
|
|
||||||
useEffect(() => {
|
|
||||||
if (user && (user.role === 'directeur' || user.role === 'manager') && selectedStoreId) {
|
|
||||||
console.log('🔄 FORCE REFRESH: selectedStoreId changed for directeur/manager:', {
|
|
||||||
userRole: user.role,
|
|
||||||
selectedStoreId,
|
|
||||||
timestamp: new Date().toISOString()
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["/api/tasks"] });
|
|
||||||
}
|
|
||||||
}, [selectedStoreId, user, queryClient]);
|
|
||||||
|
|
||||||
// États locaux
|
// États locaux
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
@@ -322,9 +299,15 @@ export default function Tasks() {
|
|||||||
const [selectedTask, setSelectedTask] = useState<TaskWithRelations | null>(null);
|
const [selectedTask, setSelectedTask] = useState<TaskWithRelations | null>(null);
|
||||||
const [taskToDelete, setTaskToDelete] = useState<TaskWithRelations | null>(null);
|
const [taskToDelete, setTaskToDelete] = useState<TaskWithRelations | null>(null);
|
||||||
|
|
||||||
// Fetch tasks - attendre que l'initialisation des stores soit terminée pour les admins
|
// Clé propre à cette page : le tableau de bord utilise ['/api/tasks', storeId]
|
||||||
const { data: tasks = [], isLoading, error } = useQuery({
|
// avec une URL différente pour les non-admins (sans storeId), sa réponse ne doit
|
||||||
queryKey: ["/api/tasks", selectedStoreId],
|
// donc jamais être réutilisée ici. Le changement de magasin change la clé, ce
|
||||||
|
// qui suffit à recharger la liste (plus d'invalidation forcée au montage).
|
||||||
|
const tasksQueryKey = ["/api/tasks", { storeId: selectedStoreId }];
|
||||||
|
|
||||||
|
// Fetch tasks
|
||||||
|
const { data: tasks = NO_TASKS, isLoading, isPlaceholderData } = useQuery({
|
||||||
|
queryKey: tasksQueryKey,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
@@ -332,16 +315,6 @@ export default function Tasks() {
|
|||||||
if (selectedStoreId) {
|
if (selectedStoreId) {
|
||||||
params.append('storeId', selectedStoreId.toString());
|
params.append('storeId', selectedStoreId.toString());
|
||||||
}
|
}
|
||||||
console.log('📋 TASKS QUERY - Fetching with params:', {
|
|
||||||
selectedStoreId,
|
|
||||||
userRole: user?.role,
|
|
||||||
storeInitialized,
|
|
||||||
url: `/api/tasks?${params.toString()}`,
|
|
||||||
willFilterByStore: !!selectedStoreId,
|
|
||||||
enabled: !!user,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
userGroups: user?.userGroups?.map((ug: any) => ug.groupId) || 'NONE'
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await fetch(`/api/tasks?${params.toString()}`, {
|
const response = await fetch(`/api/tasks?${params.toString()}`, {
|
||||||
credentials: 'include'
|
credentials: 'include'
|
||||||
@@ -353,18 +326,6 @@ export default function Tasks() {
|
|||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
console.log('📋 TASKS QUERY - Response received:', {
|
|
||||||
dataType: typeof data,
|
|
||||||
isArray: Array.isArray(data),
|
|
||||||
length: data?.length,
|
|
||||||
firstTask: data?.[0] ? {
|
|
||||||
id: data[0].id,
|
|
||||||
title: data[0].title,
|
|
||||||
hasStartDate: !!data[0].startDate,
|
|
||||||
hasGroup: !!data[0].group
|
|
||||||
} : null
|
|
||||||
});
|
|
||||||
|
|
||||||
// Valider et nettoyer les données reçues
|
// Valider et nettoyer les données reçues
|
||||||
if (!Array.isArray(data)) {
|
if (!Array.isArray(data)) {
|
||||||
console.error('Tasks API returned non-array data:', data);
|
console.error('Tasks API returned non-array data:', data);
|
||||||
@@ -379,31 +340,33 @@ export default function Tasks() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
enabled: !!user, // Charger les données dès que l'utilisateur est connecté
|
enabled: !!user, // Charger les données dès que l'utilisateur est connecté
|
||||||
|
// Au changement de magasin, la liste précédente reste affichée (estompée et
|
||||||
|
// non cliquable, avec un indicateur) au lieu d'un spinner plein écran
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch users for task assignment - seulement pour admin/manager/directeur
|
// Mise à jour optimiste de la liste affichée ; renvoie l'état précédent pour
|
||||||
const { data: users = [] } = useQuery({
|
// pouvoir le restaurer si le serveur refuse l'opération
|
||||||
queryKey: ["/api/users"],
|
const updateCachedTasks = async (update: (list: TaskWithRelations[]) => TaskWithRelations[]) => {
|
||||||
queryFn: () => fetch('/api/users', {
|
const queryKey = tasksQueryKey;
|
||||||
credentials: 'include'
|
await queryClient.cancelQueries({ queryKey });
|
||||||
}).then(res => {
|
const previousTasks = queryClient.getQueryData<TaskWithRelations[]>(queryKey);
|
||||||
if (!res.ok) {
|
if (Array.isArray(previousTasks)) {
|
||||||
throw new Error(`HTTP error! status: ${res.status}`);
|
queryClient.setQueryData<TaskWithRelations[]>(queryKey, update(previousTasks));
|
||||||
}
|
}
|
||||||
return res.json();
|
return { queryKey, previousTasks };
|
||||||
}),
|
|
||||||
enabled: !!user && (user.role === 'admin' || user.role === 'manager' || user.role === 'directeur'),
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const handleEditTask = (task: TaskWithRelations) => {
|
|
||||||
setSelectedTask(task);
|
|
||||||
setShowEditModal(true);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCompleteTask = async (taskId: number) => {
|
const restoreCachedTasks = (context?: { queryKey: unknown[]; previousTasks?: TaskWithRelations[] }) => {
|
||||||
try {
|
if (context?.previousTasks) {
|
||||||
|
queryClient.setQueryData(context.queryKey, context.previousTasks);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Terminer : la tâche passe tout de suite dans les tâches terminées, puis la
|
||||||
|
// liste est rechargée depuis le serveur
|
||||||
|
const completeTaskMutation = useMutation({
|
||||||
|
mutationFn: async (taskId: number) => {
|
||||||
const response = await fetch(`/api/tasks/${taskId}/complete`, {
|
const response = await fetch(`/api/tasks/${taskId}/complete`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -416,32 +379,39 @@ export default function Tasks() {
|
|||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Erreur lors de la completion de la tâche');
|
throw new Error('Erreur lors de la completion de la tâche');
|
||||||
}
|
}
|
||||||
|
},
|
||||||
await queryClient.invalidateQueries({ queryKey: ["/api/tasks"] });
|
onMutate: (taskId: number) => {
|
||||||
|
const now = new Date();
|
||||||
|
return updateCachedTasks((list) => list.map((task) =>
|
||||||
|
task.id === taskId
|
||||||
|
? { ...task, status: 'completed', completedAt: now, completedBy: user?.id ?? task.completedBy, updatedAt: now }
|
||||||
|
: task
|
||||||
|
));
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
toast({
|
toast({
|
||||||
title: "Succès",
|
title: "Succès",
|
||||||
description: "Tâche marquée comme terminée",
|
description: "Tâche marquée comme terminée",
|
||||||
});
|
});
|
||||||
} catch (error) {
|
},
|
||||||
|
onError: (error, _taskId, context) => {
|
||||||
|
restoreCachedTasks(context);
|
||||||
console.error("Error completing task:", error);
|
console.error("Error completing task:", error);
|
||||||
toast({
|
toast({
|
||||||
title: "Erreur",
|
title: "Erreur",
|
||||||
description: "Impossible de terminer la tâche",
|
description: "Impossible de terminer la tâche",
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
});
|
});
|
||||||
}
|
},
|
||||||
};
|
onSettled: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["/api/tasks"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const handleDeleteClick = (task: TaskWithRelations) => {
|
// Supprimer : la tâche disparaît tout de suite de la liste et la modale se ferme
|
||||||
setTaskToDelete(task);
|
const deleteTaskMutation = useMutation({
|
||||||
setShowDeleteModal(true);
|
mutationFn: async (taskId: number) => {
|
||||||
};
|
const response = await fetch(`/api/tasks/${taskId}`, {
|
||||||
|
|
||||||
const handleConfirmDelete = async () => {
|
|
||||||
if (!taskToDelete) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/tasks/${taskToDelete.id}`, {
|
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
});
|
});
|
||||||
@@ -449,86 +419,117 @@ export default function Tasks() {
|
|||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Erreur lors de la suppression');
|
throw new Error('Erreur lors de la suppression');
|
||||||
}
|
}
|
||||||
|
},
|
||||||
await queryClient.invalidateQueries({ queryKey: ["/api/tasks"] });
|
onMutate: (taskId: number) => updateCachedTasks((list) => list.filter((task) => task.id !== taskId)),
|
||||||
|
onSuccess: () => {
|
||||||
toast({
|
toast({
|
||||||
title: "Succès",
|
title: "Succès",
|
||||||
description: "Tâche supprimée avec succès",
|
description: "Tâche supprimée avec succès",
|
||||||
});
|
});
|
||||||
|
},
|
||||||
setShowDeleteModal(false);
|
onError: (error, _taskId, context) => {
|
||||||
setTaskToDelete(null);
|
restoreCachedTasks(context);
|
||||||
} catch (error) {
|
|
||||||
console.error("Error deleting task:", error);
|
console.error("Error deleting task:", error);
|
||||||
toast({
|
toast({
|
||||||
title: "Erreur",
|
title: "Erreur",
|
||||||
description: "Impossible de supprimer la tâche",
|
description: "Impossible de supprimer la tâche",
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
});
|
});
|
||||||
}
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["/api/tasks"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleEditTask = (task: TaskWithRelations) => {
|
||||||
|
setSelectedTask(task);
|
||||||
|
setShowEditModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Filtrer et trier les tâches
|
const handleCompleteTask = (taskId: number) => {
|
||||||
const filteredTasks = tasks
|
completeTaskMutation.mutate(taskId);
|
||||||
.filter((task: TaskWithRelations) => {
|
};
|
||||||
// Filtre par recherche
|
|
||||||
if (searchTerm && !task.title.toLowerCase().includes(searchTerm.toLowerCase()) &&
|
|
||||||
!task.description?.toLowerCase().includes(searchTerm.toLowerCase())) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filtre par statut
|
const handleDeleteClick = (task: TaskWithRelations) => {
|
||||||
if (statusFilter !== "all" && task.status !== statusFilter) {
|
setTaskToDelete(task);
|
||||||
return false;
|
setShowDeleteModal(true);
|
||||||
}
|
};
|
||||||
|
|
||||||
// Filtre par priorité
|
const handleConfirmDelete = () => {
|
||||||
if (priorityFilter !== "all" && task.priority !== priorityFilter) {
|
if (!taskToDelete) return;
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filtre par date d'échéance
|
deleteTaskMutation.mutate(taskToDelete.id);
|
||||||
if (dueDateFilter !== "all" && task.dueDate) {
|
setShowDeleteModal(false);
|
||||||
const dueDate = new Date(task.dueDate);
|
setTaskToDelete(null);
|
||||||
switch (dueDateFilter) {
|
};
|
||||||
case "today":
|
|
||||||
if (!isToday(dueDate)) return false;
|
// La recherche est différée : la saisie reste fluide pendant le filtrage
|
||||||
break;
|
const deferredSearchTerm = useDeferredValue(searchTerm);
|
||||||
case "this_week":
|
|
||||||
if (!isThisWeek(dueDate)) return false;
|
// Filtrer et trier les tâches (recalculé seulement quand la liste ou un filtre change)
|
||||||
break;
|
const filteredTasks = useMemo(() => {
|
||||||
case "overdue":
|
const search = deferredSearchTerm.toLowerCase();
|
||||||
if (!isPast(dueDate) || task.status === 'completed') return false;
|
return tasks
|
||||||
break;
|
.filter((task: TaskWithRelations) => {
|
||||||
case "no_due_date":
|
// Filtre par recherche
|
||||||
if (task.dueDate) return false;
|
if (search && !task.title.toLowerCase().includes(search) &&
|
||||||
break;
|
!task.description?.toLowerCase().includes(search)) {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
} else if (dueDateFilter === "no_due_date" && task.dueDate) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
// Filtre par statut
|
||||||
})
|
if (statusFilter !== "all" && task.status !== statusFilter) {
|
||||||
.sort((a: TaskWithRelations, b: TaskWithRelations) => {
|
return false;
|
||||||
// Faire remonter les tâches non validées (pending) en premier
|
}
|
||||||
if (a.status === 'pending' && b.status === 'completed') return -1;
|
|
||||||
if (a.status === 'completed' && b.status === 'pending') return 1;
|
|
||||||
|
|
||||||
// Pour les tâches de même statut, trier par priorité (high > medium > low)
|
// Filtre par priorité
|
||||||
const priorityOrder = { 'high': 3, 'medium': 2, 'low': 1 };
|
if (priorityFilter !== "all" && task.priority !== priorityFilter) {
|
||||||
const aPriority = priorityOrder[a.priority as keyof typeof priorityOrder] || 2;
|
return false;
|
||||||
const bPriority = priorityOrder[b.priority as keyof typeof priorityOrder] || 2;
|
}
|
||||||
|
|
||||||
if (aPriority !== bPriority) {
|
// Filtre par date d'échéance
|
||||||
return bPriority - aPriority; // Ordre décroissant (high en premier)
|
if (dueDateFilter !== "all" && task.dueDate) {
|
||||||
}
|
const dueDate = new Date(task.dueDate);
|
||||||
|
switch (dueDateFilter) {
|
||||||
|
case "today":
|
||||||
|
if (!isToday(dueDate)) return false;
|
||||||
|
break;
|
||||||
|
case "this_week":
|
||||||
|
if (!isThisWeek(dueDate)) return false;
|
||||||
|
break;
|
||||||
|
case "overdue":
|
||||||
|
if (!isPast(dueDate) || task.status === 'completed') return false;
|
||||||
|
break;
|
||||||
|
case "no_due_date":
|
||||||
|
if (task.dueDate) return false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else if (dueDateFilter === "no_due_date" && task.dueDate) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// Enfin, trier par date de création (plus récent en premier)
|
return true;
|
||||||
const aDate = a.createdAt ? new Date(a.createdAt).getTime() : 0;
|
})
|
||||||
const bDate = b.createdAt ? new Date(b.createdAt).getTime() : 0;
|
.sort((a: TaskWithRelations, b: TaskWithRelations) => {
|
||||||
return bDate - aDate;
|
// Faire remonter les tâches non validées (pending) en premier
|
||||||
});
|
if (a.status === 'pending' && b.status === 'completed') return -1;
|
||||||
|
if (a.status === 'completed' && b.status === 'pending') return 1;
|
||||||
|
|
||||||
|
// Pour les tâches de même statut, trier par priorité (high > medium > low)
|
||||||
|
const priorityOrder = { 'high': 3, 'medium': 2, 'low': 1 };
|
||||||
|
const aPriority = priorityOrder[a.priority as keyof typeof priorityOrder] || 2;
|
||||||
|
const bPriority = priorityOrder[b.priority as keyof typeof priorityOrder] || 2;
|
||||||
|
|
||||||
|
if (aPriority !== bPriority) {
|
||||||
|
return bPriority - aPriority; // Ordre décroissant (high en premier)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enfin, trier par date de création (plus récent en premier)
|
||||||
|
const aDate = a.createdAt ? new Date(a.createdAt).getTime() : 0;
|
||||||
|
const bDate = b.createdAt ? new Date(b.createdAt).getTime() : 0;
|
||||||
|
return bDate - aDate;
|
||||||
|
});
|
||||||
|
}, [tasks, deferredSearchTerm, statusFilter, priorityFilter, dueDateFilter]);
|
||||||
|
|
||||||
// Pagination
|
// Pagination
|
||||||
const {
|
const {
|
||||||
@@ -541,6 +542,18 @@ export default function Tasks() {
|
|||||||
totalItems
|
totalItems
|
||||||
} = usePagination(filteredTasks, 10);
|
} = usePagination(filteredTasks, 10);
|
||||||
|
|
||||||
|
// Sous-listes calculées une seule fois par rendu (page courante et Kanban)
|
||||||
|
const pagePendingTasks = paginatedTasks.filter((task: TaskWithRelations) => task.status === 'pending');
|
||||||
|
const pageCompletedTasks = paginatedTasks.filter((task: TaskWithRelations) => task.status === 'completed');
|
||||||
|
const { kanbanPendingTasks, kanbanCompletedTasks } = useMemo(() => ({
|
||||||
|
kanbanPendingTasks: filteredTasks.filter((task: TaskWithRelations) => task.status === 'pending'),
|
||||||
|
kanbanCompletedTasks: filteredTasks.filter((task: TaskWithRelations) => task.status === 'completed'),
|
||||||
|
}), [filteredTasks]);
|
||||||
|
|
||||||
|
// Liste du magasin précédent affichée pendant le chargement du nouveau magasin :
|
||||||
|
// estompée et non cliquable pour ne pas agir sur une tâche de l'ancien magasin
|
||||||
|
const listBusyClass = isPlaceholderData ? "opacity-60 pointer-events-none transition-opacity" : "";
|
||||||
|
|
||||||
const getPriorityConfig = (priority: string) => {
|
const getPriorityConfig = (priority: string) => {
|
||||||
switch (priority) {
|
switch (priority) {
|
||||||
case 'high':
|
case 'high':
|
||||||
@@ -687,6 +700,12 @@ export default function Tasks() {
|
|||||||
</h2>
|
</h2>
|
||||||
<p className="text-gray-600 mt-1 text-sm sm:text-base">
|
<p className="text-gray-600 mt-1 text-sm sm:text-base">
|
||||||
{totalItems} tâche{totalItems !== 1 ? 's' : ''} trouvée{totalItems !== 1 ? 's' : ''}
|
{totalItems} tâche{totalItems !== 1 ? 's' : ''} trouvée{totalItems !== 1 ? 's' : ''}
|
||||||
|
{isPlaceholderData && (
|
||||||
|
<span className="inline-flex items-center gap-1 ml-2 text-xs text-gray-500" role="status">
|
||||||
|
<span className="animate-spin rounded-full h-3 w-3 border-b-2 border-primary" />
|
||||||
|
Mise à jour…
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3">
|
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3">
|
||||||
@@ -757,8 +776,9 @@ export default function Tasks() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Section filtres - Responsive */}
|
{/* Section filtres - Responsive (tablette uniquement) */}
|
||||||
<div className={`${filtersOpen ? 'block' : 'hidden'} lg:hidden mb-4`}>
|
{!isLargeScreen && (
|
||||||
|
<div className={`${filtersOpen ? 'block' : 'hidden'} mb-4`}>
|
||||||
<Card className="mb-4 sm:mb-6">
|
<Card className="mb-4 sm:mb-6">
|
||||||
<CardHeader className="pb-3 sm:pb-4">
|
<CardHeader className="pb-3 sm:pb-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -846,9 +866,11 @@ export default function Tasks() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Zone principale mobile/tablet - Contenu pleine largeur */}
|
{/* Zone principale mobile/tablet - Contenu pleine largeur */}
|
||||||
<div className="block lg:hidden">
|
{!isLargeScreen && (
|
||||||
|
<div className={listBusyClass} aria-busy={isPlaceholderData}>
|
||||||
<Tabs value={viewMode} onValueChange={setViewMode} className="h-full">
|
<Tabs value={viewMode} onValueChange={setViewMode} className="h-full">
|
||||||
<TabsContent value="list" className="mt-0">
|
<TabsContent value="list" className="mt-0">
|
||||||
{/* Contenu mobile identique au desktop mais optimisé */}
|
{/* Contenu mobile identique au desktop mais optimisé */}
|
||||||
@@ -865,14 +887,13 @@ export default function Tasks() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-3 sm:space-y-4">
|
<div className="space-y-3 sm:space-y-4">
|
||||||
{/* Tâches en cours - Mobile optimisé */}
|
{/* Tâches en cours - Mobile optimisé */}
|
||||||
{paginatedTasks.filter(task => task.status === 'pending').length > 0 && (
|
{pagePendingTasks.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-base sm:text-lg font-semibold text-gray-900 mb-3 px-1">
|
<h4 className="text-base sm:text-lg font-semibold text-gray-900 mb-3 px-1">
|
||||||
Tâches en cours ({paginatedTasks.filter(task => task.status === 'pending').length})
|
Tâches en cours ({pagePendingTasks.length})
|
||||||
</h4>
|
</h4>
|
||||||
<div className="space-y-2 sm:space-y-3">
|
<div className="space-y-2 sm:space-y-3">
|
||||||
{paginatedTasks
|
{pagePendingTasks
|
||||||
.filter(task => task.status === 'pending')
|
|
||||||
.map((task) => {
|
.map((task) => {
|
||||||
const priorityConfig = getPriorityConfig(task.priority);
|
const priorityConfig = getPriorityConfig(task.priority);
|
||||||
const PriorityIcon = priorityConfig.icon;
|
const PriorityIcon = priorityConfig.icon;
|
||||||
@@ -1024,14 +1045,13 @@ export default function Tasks() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tâches terminées - Mobile optimisé */}
|
{/* Tâches terminées - Mobile optimisé */}
|
||||||
{paginatedTasks.filter(task => task.status === 'completed').length > 0 && (
|
{pageCompletedTasks.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-base sm:text-lg font-semibold text-gray-900 mb-3 mt-6 px-1">
|
<h4 className="text-base sm:text-lg font-semibold text-gray-900 mb-3 mt-6 px-1">
|
||||||
Tâches terminées ({paginatedTasks.filter(task => task.status === 'completed').length})
|
Tâches terminées ({pageCompletedTasks.length})
|
||||||
</h4>
|
</h4>
|
||||||
<div className="space-y-2 sm:space-y-3">
|
<div className="space-y-2 sm:space-y-3">
|
||||||
{paginatedTasks
|
{pageCompletedTasks
|
||||||
.filter(task => task.status === 'completed')
|
|
||||||
.map((task) => {
|
.map((task) => {
|
||||||
const priorityConfig = getPriorityConfig(task.priority);
|
const priorityConfig = getPriorityConfig(task.priority);
|
||||||
const PriorityIcon = priorityConfig.icon;
|
const PriorityIcon = priorityConfig.icon;
|
||||||
@@ -1088,9 +1108,11 @@ export default function Tasks() {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Sidebar desktop uniquement */}
|
{/* Sidebar desktop uniquement */}
|
||||||
<div className="hidden lg:flex gap-6">
|
{isLargeScreen && (
|
||||||
|
<div className="flex gap-6">
|
||||||
{/* Sidebar avec filtres desktop */}
|
{/* Sidebar avec filtres desktop */}
|
||||||
<div className="w-80 bg-gray-50 border-r border-gray-200 p-4 flex-shrink-0">
|
<div className="w-80 bg-gray-50 border-r border-gray-200 p-4 flex-shrink-0">
|
||||||
<Card>
|
<Card>
|
||||||
@@ -1167,7 +1189,7 @@ export default function Tasks() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Zone principale avec les tâches - Desktop */}
|
{/* Zone principale avec les tâches - Desktop */}
|
||||||
<div className="flex-1">
|
<div className={`flex-1 ${listBusyClass}`} aria-busy={isPlaceholderData}>
|
||||||
{/* Contenu selon la vue sélectionnée */}
|
{/* Contenu selon la vue sélectionnée */}
|
||||||
<Tabs value={viewMode} onValueChange={setViewMode} className="h-full">
|
<Tabs value={viewMode} onValueChange={setViewMode} className="h-full">
|
||||||
<TabsContent value="list" className="mt-0">
|
<TabsContent value="list" className="mt-0">
|
||||||
@@ -1186,14 +1208,13 @@ export default function Tasks() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Tâches en cours */}
|
{/* Tâches en cours */}
|
||||||
{paginatedTasks.filter(task => task.status === 'pending').length > 0 && (
|
{pagePendingTasks.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-lg font-semibold text-gray-900 mb-3">
|
<h4 className="text-lg font-semibold text-gray-900 mb-3">
|
||||||
Tâches en cours ({paginatedTasks.filter(task => task.status === 'pending').length})
|
Tâches en cours ({pagePendingTasks.length})
|
||||||
</h4>
|
</h4>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{paginatedTasks
|
{pagePendingTasks
|
||||||
.filter(task => task.status === 'pending')
|
|
||||||
.map((task) => {
|
.map((task) => {
|
||||||
const priorityConfig = getPriorityConfig(task.priority);
|
const priorityConfig = getPriorityConfig(task.priority);
|
||||||
const PriorityIcon = priorityConfig.icon;
|
const PriorityIcon = priorityConfig.icon;
|
||||||
@@ -1334,14 +1355,13 @@ export default function Tasks() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tâches terminées */}
|
{/* Tâches terminées */}
|
||||||
{paginatedTasks.filter(task => task.status === 'completed').length > 0 && (
|
{pageCompletedTasks.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-lg font-semibold text-gray-900 mb-3 mt-8">
|
<h4 className="text-lg font-semibold text-gray-900 mb-3 mt-8">
|
||||||
Tâches terminées ({paginatedTasks.filter(task => task.status === 'completed').length})
|
Tâches terminées ({pageCompletedTasks.length})
|
||||||
</h4>
|
</h4>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{paginatedTasks
|
{pageCompletedTasks
|
||||||
.filter(task => task.status === 'completed')
|
|
||||||
.map((task) => {
|
.map((task) => {
|
||||||
const priorityConfig = getPriorityConfig(task.priority);
|
const priorityConfig = getPriorityConfig(task.priority);
|
||||||
const PriorityIcon = priorityConfig.icon;
|
const PriorityIcon = priorityConfig.icon;
|
||||||
@@ -1426,10 +1446,10 @@ export default function Tasks() {
|
|||||||
<div className="bg-gray-50 rounded-lg p-4">
|
<div className="bg-gray-50 rounded-lg p-4">
|
||||||
<h3 className="font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
<h3 className="font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
||||||
<Circle className="w-5 h-5 text-yellow-500" />
|
<Circle className="w-5 h-5 text-yellow-500" />
|
||||||
En cours ({filteredTasks.filter((task: TaskWithRelations) => task.status === 'pending').length})
|
En cours ({kanbanPendingTasks.length})
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-3 max-h-[600px] overflow-y-auto">
|
<div className="space-y-3 max-h-[600px] overflow-y-auto">
|
||||||
{filteredTasks.filter((task: TaskWithRelations) => task.status === 'pending').map((task: TaskWithRelations) => {
|
{kanbanPendingTasks.map((task: TaskWithRelations) => {
|
||||||
const priorityConfig = getPriorityConfig(task.priority);
|
const priorityConfig = getPriorityConfig(task.priority);
|
||||||
const PriorityIcon = priorityConfig.icon;
|
const PriorityIcon = priorityConfig.icon;
|
||||||
const dueDateStatus = getDueDateStatus(task.dueDate, task.status);
|
const dueDateStatus = getDueDateStatus(task.dueDate, task.status);
|
||||||
@@ -1506,10 +1526,10 @@ export default function Tasks() {
|
|||||||
<div className="bg-green-50 rounded-lg p-4">
|
<div className="bg-green-50 rounded-lg p-4">
|
||||||
<h3 className="font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
<h3 className="font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
||||||
<CheckCircle className="w-5 h-5 text-green-500" />
|
<CheckCircle className="w-5 h-5 text-green-500" />
|
||||||
Terminées ({filteredTasks.filter((task: TaskWithRelations) => task.status === 'completed').length})
|
Terminées ({kanbanCompletedTasks.length})
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-3 max-h-[600px] overflow-y-auto">
|
<div className="space-y-3 max-h-[600px] overflow-y-auto">
|
||||||
{filteredTasks.filter((task: TaskWithRelations) => task.status === 'completed').map((task: TaskWithRelations) => {
|
{kanbanCompletedTasks.map((task: TaskWithRelations) => {
|
||||||
const priorityConfig = getPriorityConfig(task.priority);
|
const priorityConfig = getPriorityConfig(task.priority);
|
||||||
const PriorityIcon = priorityConfig.icon;
|
const PriorityIcon = priorityConfig.icon;
|
||||||
|
|
||||||
@@ -1562,6 +1582,7 @@ export default function Tasks() {
|
|||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Modal d'édition */}
|
{/* Modal d'édition */}
|
||||||
{showEditModal && selectedTask && (
|
{showEditModal && selectedTask && (
|
||||||
|
|||||||
+19
-16
@@ -28,7 +28,7 @@ import {
|
|||||||
import type { UserWithGroups, Group } from "@shared/schema";
|
import type { UserWithGroups, Group } from "@shared/schema";
|
||||||
|
|
||||||
export default function UsersPage() {
|
export default function UsersPage() {
|
||||||
const { user } = useAuthUnified();
|
const { user, refreshAuth } = useAuthUnified();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
@@ -132,12 +132,17 @@ export default function UsersPage() {
|
|||||||
const response = await apiRequest(`/api/users/${data.id}`, "PUT", cleanedUpdates);
|
const response = await apiRequest(`/api/users/${data.id}`, "PUT", cleanedUpdates);
|
||||||
return response;
|
return response;
|
||||||
},
|
},
|
||||||
onSuccess: (updatedUser) => {
|
onSuccess: (updatedUser, variables) => {
|
||||||
toast({
|
toast({
|
||||||
title: "Succès",
|
title: "Succès",
|
||||||
description: "Utilisateur mis à jour avec succès",
|
description: "Utilisateur mis à jour avec succès",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Compte de l'administrateur connecté : rafraîchir l'utilisateur partagé par l'application
|
||||||
|
if (variables.id === user?.id) {
|
||||||
|
refreshAuth();
|
||||||
|
}
|
||||||
|
|
||||||
// Update the form with the response data before closing
|
// Update the form with the response data before closing
|
||||||
if (updatedUser) {
|
if (updatedUser) {
|
||||||
setEditForm({
|
setEditForm({
|
||||||
@@ -224,25 +229,24 @@ export default function UsersPage() {
|
|||||||
|
|
||||||
const assignGroupMutation = useMutation({
|
const assignGroupMutation = useMutation({
|
||||||
mutationFn: async (data: { userId: string; groupId: number }) => {
|
mutationFn: async (data: { userId: string; groupId: number }) => {
|
||||||
console.log('📤 Assigning group:', data);
|
|
||||||
const response = await apiRequest(
|
const response = await apiRequest(
|
||||||
`/api/users/${data.userId}/groups`,
|
`/api/users/${data.userId}/groups`,
|
||||||
"POST",
|
"POST",
|
||||||
{ groupId: data.groupId }
|
{ groupId: data.groupId }
|
||||||
);
|
);
|
||||||
console.log('✅ Group assignment response:', response);
|
|
||||||
return response;
|
return response;
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: (_response, variables) => {
|
||||||
console.log('✅ Group assigned successfully');
|
|
||||||
toast({
|
toast({
|
||||||
title: "Succès",
|
title: "Succès",
|
||||||
description: "Utilisateur assigné au groupe avec succès",
|
description: "Utilisateur assigné au groupe avec succès",
|
||||||
});
|
});
|
||||||
// Invalidation complète du cache
|
// Seule la liste des utilisateurs change : la liste des magasins
|
||||||
|
// (['/api/groups']) ne dépend pas des affectations
|
||||||
queryClient.invalidateQueries({ queryKey: ['/api/users'] });
|
queryClient.invalidateQueries({ queryKey: ['/api/users'] });
|
||||||
|
if (variables.userId === user?.id) {
|
||||||
queryClient.invalidateQueries({ queryKey: ['/api/groups'] });
|
refreshAuth();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
console.error('❌ Error assigning group:', error);
|
console.error('❌ Error assigning group:', error);
|
||||||
@@ -270,24 +274,23 @@ export default function UsersPage() {
|
|||||||
|
|
||||||
const removeGroupMutation = useMutation({
|
const removeGroupMutation = useMutation({
|
||||||
mutationFn: async (data: { userId: string; groupId: number }) => {
|
mutationFn: async (data: { userId: string; groupId: number }) => {
|
||||||
console.log('🗑️ Removing group:', data);
|
|
||||||
const response = await apiRequest(
|
const response = await apiRequest(
|
||||||
`/api/users/${data.userId}/groups/${data.groupId}`,
|
`/api/users/${data.userId}/groups/${data.groupId}`,
|
||||||
"DELETE"
|
"DELETE"
|
||||||
);
|
);
|
||||||
console.log('✅ Group removal response:', response);
|
|
||||||
return response;
|
return response;
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: (_response, variables) => {
|
||||||
console.log('✅ Group removed successfully');
|
|
||||||
toast({
|
toast({
|
||||||
title: "Succès",
|
title: "Succès",
|
||||||
description: "Utilisateur retiré du groupe avec succès",
|
description: "Utilisateur retiré du groupe avec succès",
|
||||||
});
|
});
|
||||||
// Invalidation complète du cache
|
// Seule la liste des utilisateurs change : la liste des magasins
|
||||||
|
// (['/api/groups']) ne dépend pas des affectations
|
||||||
queryClient.invalidateQueries({ queryKey: ['/api/users'] });
|
queryClient.invalidateQueries({ queryKey: ['/api/users'] });
|
||||||
|
if (variables.userId === user?.id) {
|
||||||
queryClient.invalidateQueries({ queryKey: ['/api/groups'] });
|
refreshAuth();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
console.error('❌ Error removing group:', error);
|
console.error('❌ Error removing group:', error);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
Cloud,
|
Cloud,
|
||||||
Send,
|
Send,
|
||||||
Code,
|
Code,
|
||||||
|
Plug,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
@@ -21,6 +22,7 @@ import DatabaseDebug from "./DatabaseDebug";
|
|||||||
import WeatherSettings from "./WeatherSettings";
|
import WeatherSettings from "./WeatherSettings";
|
||||||
import WebhookBAPConfig from "./WebhookBAPConfig";
|
import WebhookBAPConfig from "./WebhookBAPConfig";
|
||||||
import SQLExecutor from "./SQLExecutor";
|
import SQLExecutor from "./SQLExecutor";
|
||||||
|
import ExternalApiSettings from "./ExternalApiSettings";
|
||||||
|
|
||||||
export default function Utilities() {
|
export default function Utilities() {
|
||||||
const { user } = useAuthUnified();
|
const { user } = useAuthUnified();
|
||||||
@@ -31,6 +33,7 @@ export default function Utilities() {
|
|||||||
if (path === '/nocodb-config') return 'nocodb';
|
if (path === '/nocodb-config') return 'nocodb';
|
||||||
if (path === '/database-debug') return 'debug';
|
if (path === '/database-debug') return 'debug';
|
||||||
if (path === '/weather-settings') return 'weather';
|
if (path === '/weather-settings') return 'weather';
|
||||||
|
if (new URLSearchParams(window.location.search).get('tab') === 'api') return 'api';
|
||||||
return 'backups';
|
return 'backups';
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -41,6 +44,7 @@ export default function Utilities() {
|
|||||||
const canManageWeather = user?.role === 'admin';
|
const canManageWeather = user?.role === 'admin';
|
||||||
const canManageWebhookBAP = user?.role === 'admin';
|
const canManageWebhookBAP = user?.role === 'admin';
|
||||||
const canExecuteSQL = user?.role === 'admin';
|
const canExecuteSQL = user?.role === 'admin';
|
||||||
|
const canManageExternalApi = user?.role === 'admin';
|
||||||
// Si l'utilisateur n'a aucune permission, afficher le message d'accès refusé
|
// Si l'utilisateur n'a aucune permission, afficher le message d'accès refusé
|
||||||
if (!canManageBackups && !canManageNocoDB && !canDebugDatabase && !canManageWeather && !canManageWebhookBAP && !canExecuteSQL) {
|
if (!canManageBackups && !canManageNocoDB && !canDebugDatabase && !canManageWeather && !canManageWebhookBAP && !canExecuteSQL) {
|
||||||
return (
|
return (
|
||||||
@@ -78,7 +82,7 @@ export default function Utilities() {
|
|||||||
{/* Content avec onglets */}
|
{/* Content avec onglets */}
|
||||||
<div className="flex-1 p-6 overflow-hidden">
|
<div className="flex-1 p-6 overflow-hidden">
|
||||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="h-full flex flex-col">
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="h-full flex flex-col">
|
||||||
<TabsList className="grid w-full grid-cols-6 mb-6">
|
<TabsList className="flex flex-wrap h-auto w-full justify-start gap-1 mb-6">
|
||||||
{canManageBackups && (
|
{canManageBackups && (
|
||||||
<TabsTrigger value="backups" className="flex items-center gap-2">
|
<TabsTrigger value="backups" className="flex items-center gap-2">
|
||||||
<Database className="w-4 h-4" />
|
<Database className="w-4 h-4" />
|
||||||
@@ -109,6 +113,12 @@ export default function Utilities() {
|
|||||||
Configuration BAP
|
Configuration BAP
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
)}
|
)}
|
||||||
|
{canManageExternalApi && (
|
||||||
|
<TabsTrigger value="api" className="flex items-center gap-2">
|
||||||
|
<Plug className="w-4 h-4" />
|
||||||
|
API externe
|
||||||
|
</TabsTrigger>
|
||||||
|
)}
|
||||||
{canExecuteSQL && (
|
{canExecuteSQL && (
|
||||||
<TabsTrigger value="sql" className="flex items-center gap-2">
|
<TabsTrigger value="sql" className="flex items-center gap-2">
|
||||||
<Code className="w-4 h-4" />
|
<Code className="w-4 h-4" />
|
||||||
@@ -158,6 +168,14 @@ export default function Utilities() {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{canManageExternalApi && (
|
||||||
|
<TabsContent value="api" className="flex-1 overflow-hidden">
|
||||||
|
<div className="h-full bg-gray-50 -m-6 p-6">
|
||||||
|
<ExternalApiSettings />
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
)}
|
||||||
|
|
||||||
{canExecuteSQL && (
|
{canExecuteSQL && (
|
||||||
<TabsContent value="sql" className="flex-1 overflow-hidden">
|
<TabsContent value="sql" className="flex-1 overflow-hidden">
|
||||||
<div className="h-full bg-gray-50 -m-6 p-6">
|
<div className="h-full bg-gray-50 -m-6 p-6">
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* MobileAvoirsPage.tsx
|
* MobileAvoirsPage.tsx
|
||||||
* Version mobile de la page Gestion des Avoirs
|
* Version mobile de la page Gestion des Avoirs
|
||||||
*/
|
*/
|
||||||
import { useState } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
||||||
import { useStore } from "@/contexts/StoreContext";
|
import { useStore } from "@/contexts/StoreContext";
|
||||||
@@ -68,16 +68,20 @@ const avoirSchema = z.object({
|
|||||||
status: z.enum(["En attente de demande", "Demandé", "Reçu"]).default("En attente de demande"),
|
status: z.enum(["En attente de demande", "Demandé", "Reçu"]).default("En attente de demande"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Tableau vide stable tant que la liste n'est pas chargée (calculs mémoïsés)
|
||||||
|
const NO_AVOIRS: any[] = [];
|
||||||
|
|
||||||
export default function MobileAvoirsPage() {
|
export default function MobileAvoirsPage() {
|
||||||
const { user } = useAuthUnified();
|
const { user } = useAuthUnified();
|
||||||
const { selectedStoreId } = useStore();
|
// Magasins déjà chargés par l'application (même liste que /api/groups)
|
||||||
|
const { selectedStoreId, stores } = useStore();
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
// Queries
|
// Queries
|
||||||
const { data: avoirs = [], isLoading } = useQuery({
|
const { data: avoirs = NO_AVOIRS, isLoading } = useQuery<any[]>({
|
||||||
queryKey: ["/api/avoirs", selectedStoreId],
|
queryKey: ["/api/avoirs", selectedStoreId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!selectedStoreId) return [];
|
if (!selectedStoreId) return [];
|
||||||
@@ -88,14 +92,11 @@ export default function MobileAvoirsPage() {
|
|||||||
enabled: !!selectedStoreId && !!user,
|
enabled: !!selectedStoreId && !!user,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fournisseurs : utiles seulement dans la feuille de création, chargés à son ouverture
|
||||||
const { data: suppliers = [] } = useQuery({
|
const { data: suppliers = [] } = useQuery({
|
||||||
queryKey: ['/api/suppliers'],
|
queryKey: ['/api/suppliers'],
|
||||||
queryFn: () => apiRequest('/api/suppliers')
|
queryFn: () => apiRequest('/api/suppliers'),
|
||||||
});
|
enabled: isCreateOpen,
|
||||||
|
|
||||||
const { data: groups = [] } = useQuery({
|
|
||||||
queryKey: ['/api/groups'],
|
|
||||||
queryFn: () => apiRequest('/api/groups')
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mutations
|
// Mutations
|
||||||
@@ -142,7 +143,7 @@ export default function MobileAvoirsPage() {
|
|||||||
const onSubmit = (data: any) => {
|
const onSubmit = (data: any) => {
|
||||||
let groupId = selectedStoreId;
|
let groupId = selectedStoreId;
|
||||||
if (!groupId && user?.userGroups?.[0]?.groupId) groupId = user.userGroups[0].groupId;
|
if (!groupId && user?.userGroups?.[0]?.groupId) groupId = user.userGroups[0].groupId;
|
||||||
if (!groupId && groups.length > 0 && user?.role === 'admin') groupId = groups[0].id;
|
if (!groupId && stores.length > 0 && user?.role === 'admin') groupId = stores[0].id;
|
||||||
|
|
||||||
if (!groupId) {
|
if (!groupId) {
|
||||||
toast({ title: "Erreur", description: "Aucun magasin sélectionné", variant: "destructive" });
|
toast({ title: "Erreur", description: "Aucun magasin sélectionné", variant: "destructive" });
|
||||||
@@ -155,13 +156,31 @@ export default function MobileAvoirsPage() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const sortedAvoirs = [...avoirs].sort((a: any, b: any) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
// Tri et filtrage recalculés seulement quand la liste ou la recherche change
|
||||||
|
const sortedAvoirs = useMemo(
|
||||||
const filteredAvoirs = sortedAvoirs.filter((a: any) =>
|
() => [...avoirs].sort((a: any, b: any) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()),
|
||||||
(a.supplier?.name?.toLowerCase().includes(searchTerm.toLowerCase()) || "") ||
|
[avoirs]
|
||||||
(a.invoiceReference?.toLowerCase().includes(searchTerm.toLowerCase()) || "")
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const filteredAvoirs = useMemo(() => {
|
||||||
|
const searchLower = searchTerm.toLowerCase();
|
||||||
|
return sortedAvoirs.filter((a: any) =>
|
||||||
|
(a.supplier?.name?.toLowerCase().includes(searchLower) || "") ||
|
||||||
|
(a.invoiceReference?.toLowerCase().includes(searchLower) || "")
|
||||||
|
);
|
||||||
|
}, [sortedAvoirs, searchTerm]);
|
||||||
|
|
||||||
|
// Changement de statut : le PUT remet à null montant, référence et
|
||||||
|
// commentaire absents du corps. On envoie donc le statut et les valeurs
|
||||||
|
// actuelles de ces trois champs (une valeur nulle est omise et reste nulle),
|
||||||
|
// sans l'avoir complet et ses relations (fournisseur, magasin, créateur).
|
||||||
|
const buildStatusUpdate = (avoir: any, status: string) => ({
|
||||||
|
status,
|
||||||
|
amount: avoir.amount ?? null,
|
||||||
|
...(avoir.invoiceReference != null ? { invoiceReference: avoir.invoiceReference } : {}),
|
||||||
|
...(avoir.comment != null ? { comment: avoir.comment } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
const getStatusBadge = (status: string) => {
|
const getStatusBadge = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "En attente de demande": return <Badge className="bg-yellow-100 text-yellow-800 border-yellow-200">En attente</Badge>;
|
case "En attente de demande": return <Badge className="bg-yellow-100 text-yellow-800 border-yellow-200">En attente</Badge>;
|
||||||
@@ -238,19 +257,19 @@ export default function MobileAvoirsPage() {
|
|||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem onClick={() => updateStatusMutation.mutate({
|
<DropdownMenuItem onClick={() => updateStatusMutation.mutate({
|
||||||
id: avoir.id,
|
id: avoir.id,
|
||||||
data: { ...avoir, status: 'En attente de demande' }
|
data: buildStatusUpdate(avoir, 'En attente de demande')
|
||||||
})}>
|
})}>
|
||||||
Marquer En attente
|
Marquer En attente
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => updateStatusMutation.mutate({
|
<DropdownMenuItem onClick={() => updateStatusMutation.mutate({
|
||||||
id: avoir.id,
|
id: avoir.id,
|
||||||
data: { ...avoir, status: 'Demandé' }
|
data: buildStatusUpdate(avoir, 'Demandé')
|
||||||
})}>
|
})}>
|
||||||
Marquer Demandé
|
Marquer Demandé
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => updateStatusMutation.mutate({
|
<DropdownMenuItem onClick={() => updateStatusMutation.mutate({
|
||||||
id: avoir.id,
|
id: avoir.id,
|
||||||
data: { ...avoir, status: 'Reçu' }
|
data: buildStatusUpdate(avoir, 'Reçu')
|
||||||
})}>
|
})}>
|
||||||
Marquer Reçu
|
Marquer Reçu
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* CalendarPage Mobile - Calendrier simplifié pour mobile
|
* CalendarPage Mobile - Calendrier simplifié pour mobile
|
||||||
* Affiche les commandes et livraisons comme événements
|
* Affiche les commandes et livraisons comme événements
|
||||||
*/
|
*/
|
||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
||||||
import { useStore } from "@/contexts/StoreContext";
|
import { useStore } from "@/contexts/StoreContext";
|
||||||
@@ -42,6 +42,10 @@ type CalendarEvent = {
|
|||||||
supplier?: { name: string };
|
supplier?: { name: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Références stables (évite de recalculer l'index des événements à chaque rendu)
|
||||||
|
const NO_DATA: any[] = [];
|
||||||
|
const NO_EVENTS: CalendarEvent[] = [];
|
||||||
|
|
||||||
export default function MobileCalendarPage() {
|
export default function MobileCalendarPage() {
|
||||||
const { user } = useAuthUnified();
|
const { user } = useAuthUnified();
|
||||||
const { selectedStoreId } = useStore();
|
const { selectedStoreId } = useStore();
|
||||||
@@ -49,7 +53,7 @@ export default function MobileCalendarPage() {
|
|||||||
const [selectedDate, setSelectedDate] = useState<Date | null>(new Date());
|
const [selectedDate, setSelectedDate] = useState<Date | null>(new Date());
|
||||||
|
|
||||||
// Fetch orders for current month
|
// Fetch orders for current month
|
||||||
const { data: orders = [] } = useQuery({
|
const { data: orders = NO_DATA } = useQuery({
|
||||||
queryKey: ["/api/orders", selectedStoreId, format(currentMonth, 'yyyy-MM')],
|
queryKey: ["/api/orders", selectedStoreId, format(currentMonth, 'yyyy-MM')],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const start = format(startOfMonth(currentMonth), 'yyyy-MM-dd');
|
const start = format(startOfMonth(currentMonth), 'yyyy-MM-dd');
|
||||||
@@ -74,7 +78,7 @@ export default function MobileCalendarPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Fetch deliveries for current month
|
// Fetch deliveries for current month
|
||||||
const { data: deliveries = [] } = useQuery({
|
const { data: deliveries = NO_DATA } = useQuery({
|
||||||
queryKey: ["/api/deliveries", selectedStoreId, format(currentMonth, 'yyyy-MM')],
|
queryKey: ["/api/deliveries", selectedStoreId, format(currentMonth, 'yyyy-MM')],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const start = format(startOfMonth(currentMonth), 'yyyy-MM-dd');
|
const start = format(startOfMonth(currentMonth), 'yyyy-MM-dd');
|
||||||
@@ -98,25 +102,45 @@ export default function MobileCalendarPage() {
|
|||||||
enabled: !!user,
|
enabled: !!user,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Combine orders and deliveries into events
|
// Combine orders and deliveries into events, indexés par jour ('yyyy-MM-dd',
|
||||||
const events: CalendarEvent[] = [
|
// heure locale comme isSameDay) une seule fois par chargement au lieu d'un
|
||||||
...orders.map((order: any) => ({
|
// filtrage complet pour chaque case du calendrier
|
||||||
id: order.id,
|
const { events, eventsByDay } = useMemo(() => {
|
||||||
title: `Cmd #${order.orderNumber || order.id}`,
|
const events: CalendarEvent[] = [
|
||||||
date: order.plannedDate,
|
...orders.map((order: any) => ({
|
||||||
type: 'order' as const,
|
id: order.id,
|
||||||
status: order.status,
|
title: `Cmd #${order.orderNumber || order.id}`,
|
||||||
supplier: order.supplier
|
date: order.plannedDate,
|
||||||
})),
|
type: 'order' as const,
|
||||||
...deliveries.map((delivery: any) => ({
|
status: order.status,
|
||||||
id: delivery.id,
|
supplier: order.supplier
|
||||||
title: `Liv #${delivery.deliveryNumber || delivery.id}`,
|
})),
|
||||||
date: delivery.scheduledDate,
|
...deliveries.map((delivery: any) => ({
|
||||||
type: 'delivery' as const,
|
id: delivery.id,
|
||||||
status: delivery.status,
|
title: `Liv #${delivery.deliveryNumber || delivery.id}`,
|
||||||
supplier: delivery.supplier
|
date: delivery.scheduledDate,
|
||||||
}))
|
type: 'delivery' as const,
|
||||||
];
|
status: delivery.status,
|
||||||
|
supplier: delivery.supplier
|
||||||
|
}))
|
||||||
|
];
|
||||||
|
|
||||||
|
const eventsByDay = new Map<string, CalendarEvent[]>();
|
||||||
|
for (const event of events) {
|
||||||
|
if (!event.date) continue;
|
||||||
|
const eventDate = new Date(event.date);
|
||||||
|
if (isNaN(eventDate.getTime())) continue;
|
||||||
|
const key = format(eventDate, 'yyyy-MM-dd');
|
||||||
|
const dayEvents = eventsByDay.get(key);
|
||||||
|
if (dayEvents) {
|
||||||
|
dayEvents.push(event);
|
||||||
|
} else {
|
||||||
|
eventsByDay.set(key, [event]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { events, eventsByDay };
|
||||||
|
}, [orders, deliveries]);
|
||||||
|
|
||||||
const monthStart = startOfMonth(currentMonth);
|
const monthStart = startOfMonth(currentMonth);
|
||||||
const monthEnd = endOfMonth(currentMonth);
|
const monthEnd = endOfMonth(currentMonth);
|
||||||
@@ -126,24 +150,14 @@ export default function MobileCalendarPage() {
|
|||||||
const calendarEnd = endOfWeek(monthEnd, { locale: fr });
|
const calendarEnd = endOfWeek(monthEnd, { locale: fr });
|
||||||
const calendarDays = eachDayOfInterval({ start: calendarStart, end: calendarEnd });
|
const calendarDays = eachDayOfInterval({ start: calendarStart, end: calendarEnd });
|
||||||
|
|
||||||
// Get events for selected date
|
|
||||||
const selectedDateEvents = selectedDate
|
|
||||||
? events.filter((event) => {
|
|
||||||
if (!event.date) return false;
|
|
||||||
const eventDate = new Date(event.date);
|
|
||||||
return !isNaN(eventDate.getTime()) && isSameDay(eventDate, selectedDate);
|
|
||||||
})
|
|
||||||
: [];
|
|
||||||
|
|
||||||
// Check if a day has events
|
// Check if a day has events
|
||||||
const getEventsForDay = (day: Date) => {
|
const getEventsForDay = (day: Date) => {
|
||||||
return events.filter((event) => {
|
return eventsByDay.get(format(day, 'yyyy-MM-dd')) || NO_EVENTS;
|
||||||
if (!event.date) return false;
|
|
||||||
const eventDate = new Date(event.date);
|
|
||||||
return !isNaN(eventDate.getTime()) && isSameDay(eventDate, day);
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Get events for selected date
|
||||||
|
const selectedDateEvents = selectedDate ? getEventsForDay(selectedDate) : NO_EVENTS;
|
||||||
|
|
||||||
const navigateMonth = (direction: 'prev' | 'next') => {
|
const navigateMonth = (direction: 'prev' | 'next') => {
|
||||||
setCurrentMonth(direction === 'prev'
|
setCurrentMonth(direction === 'prev'
|
||||||
? subMonths(currentMonth, 1)
|
? subMonths(currentMonth, 1)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* MobileCustomerOrdersPage.tsx
|
* MobileCustomerOrdersPage.tsx
|
||||||
* Version mobile de la page Commandes Clients avec création
|
* Version mobile de la page Commandes Clients avec création
|
||||||
*/
|
*/
|
||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef, useMemo } from "react";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
||||||
import { useStore } from "@/contexts/StoreContext";
|
import { useStore } from "@/contexts/StoreContext";
|
||||||
@@ -77,6 +77,9 @@ const customerOrderFormSchema = z.object({
|
|||||||
deposit: z.coerce.number().optional().default(0),
|
deposit: z.coerce.number().optional().default(0),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Référence stable tant que la liste n'est pas chargée
|
||||||
|
const NO_ORDERS: any[] = [];
|
||||||
|
|
||||||
export default function MobileCustomerOrdersPage() {
|
export default function MobileCustomerOrdersPage() {
|
||||||
const { user } = useAuthUnified();
|
const { user } = useAuthUnified();
|
||||||
const { selectedStoreId } = useStore();
|
const { selectedStoreId } = useStore();
|
||||||
@@ -88,7 +91,7 @@ export default function MobileCustomerOrdersPage() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
// Queries
|
// Queries
|
||||||
const { data: orders = [], isLoading } = useQuery({
|
const { data: orders = NO_ORDERS, isLoading } = useQuery({
|
||||||
queryKey: ["/api/customer-orders", selectedStoreId],
|
queryKey: ["/api/customer-orders", selectedStoreId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!selectedStoreId) return [];
|
if (!selectedStoreId) return [];
|
||||||
@@ -267,11 +270,16 @@ export default function MobileCustomerOrdersPage() {
|
|||||||
createMutation.mutate(submitData);
|
createMutation.mutate(submitData);
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredOrders = orders.filter((order: any) =>
|
// Recalculé seulement quand la liste ou la recherche change (et non à
|
||||||
order.customerName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
// chaque frappe dans le commentaire de contact)
|
||||||
order.productDesignation.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
const filteredOrders = useMemo(() => {
|
||||||
(order.productReference && order.productReference.toLowerCase().includes(searchTerm.toLowerCase()))
|
const search = searchTerm.toLowerCase();
|
||||||
);
|
return orders.filter((order: any) =>
|
||||||
|
order.customerName.toLowerCase().includes(search) ||
|
||||||
|
order.productDesignation.toLowerCase().includes(search) ||
|
||||||
|
(order.productReference && order.productReference.toLowerCase().includes(search))
|
||||||
|
);
|
||||||
|
}, [orders, searchTerm]);
|
||||||
|
|
||||||
const getStatusColor = (status: string) => {
|
const getStatusColor = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
* DashboardPage Mobile - Tableau de bord simplifié pour mobile
|
* DashboardPage Mobile - Tableau de bord simplifié pour mobile
|
||||||
*/
|
*/
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Link } from "wouter";
|
||||||
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
||||||
import { useStore } from "@/contexts/StoreContext";
|
import { useStore } from "@/contexts/StoreContext";
|
||||||
import MobileLayout from "./MobileLayout";
|
import MobileLayout from "./MobileLayout";
|
||||||
@@ -59,22 +60,6 @@ export default function MobileDashboardPage() {
|
|||||||
const { user } = useAuthUnified();
|
const { user } = useAuthUnified();
|
||||||
const { selectedStoreId } = useStore();
|
const { selectedStoreId } = useStore();
|
||||||
|
|
||||||
// Fetch stats
|
|
||||||
const { data: stats, isLoading } = useQuery({
|
|
||||||
queryKey: ["/api/stats/dashboard", selectedStoreId],
|
|
||||||
queryFn: async () => {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (selectedStoreId) params.append('storeId', selectedStoreId.toString());
|
|
||||||
|
|
||||||
const response = await fetch(`/api/stats/dashboard?${params}`, {
|
|
||||||
credentials: 'include'
|
|
||||||
});
|
|
||||||
if (!response.ok) return null;
|
|
||||||
return response.json();
|
|
||||||
},
|
|
||||||
enabled: !!user,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fetch pending tasks count
|
// Fetch pending tasks count
|
||||||
const { data: tasks = [] } = useQuery({
|
const { data: tasks = [] } = useQuery({
|
||||||
queryKey: ["/api/tasks", selectedStoreId],
|
queryKey: ["/api/tasks", selectedStoreId],
|
||||||
@@ -92,13 +77,18 @@ export default function MobileDashboardPage() {
|
|||||||
enabled: !!user,
|
enabled: !!user,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Date du jour : le serveur filtre sur startDate/endDate (le paramètre 'date'
|
||||||
|
// était ignoré et renvoyait tout l'historique)
|
||||||
|
const today = format(new Date(), 'yyyy-MM-dd');
|
||||||
|
|
||||||
// Fetch today's orders
|
// Fetch today's orders
|
||||||
const { data: orders = [] } = useQuery({
|
const { data: orders = [] } = useQuery({
|
||||||
queryKey: ["/api/orders/today", selectedStoreId],
|
queryKey: ["/api/orders/today", selectedStoreId, today],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (selectedStoreId) params.append('storeId', selectedStoreId.toString());
|
if (selectedStoreId) params.append('storeId', selectedStoreId.toString());
|
||||||
params.append('date', format(new Date(), 'yyyy-MM-dd'));
|
params.append('startDate', today);
|
||||||
|
params.append('endDate', today);
|
||||||
|
|
||||||
const response = await fetch(`/api/orders?${params}`, {
|
const response = await fetch(`/api/orders?${params}`, {
|
||||||
credentials: 'include'
|
credentials: 'include'
|
||||||
@@ -112,11 +102,12 @@ export default function MobileDashboardPage() {
|
|||||||
|
|
||||||
// Fetch today's deliveries
|
// Fetch today's deliveries
|
||||||
const { data: deliveries = [] } = useQuery({
|
const { data: deliveries = [] } = useQuery({
|
||||||
queryKey: ["/api/deliveries/today", selectedStoreId],
|
queryKey: ["/api/deliveries/today", selectedStoreId, today],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (selectedStoreId) params.append('storeId', selectedStoreId.toString());
|
if (selectedStoreId) params.append('storeId', selectedStoreId.toString());
|
||||||
params.append('date', format(new Date(), 'yyyy-MM-dd'));
|
params.append('startDate', today);
|
||||||
|
params.append('endDate', today);
|
||||||
|
|
||||||
const response = await fetch(`/api/deliveries?${params}`, {
|
const response = await fetch(`/api/deliveries?${params}`, {
|
||||||
credentials: 'include'
|
credentials: 'include'
|
||||||
@@ -183,15 +174,16 @@ export default function MobileDashboardPage() {
|
|||||||
{ icon: Calendar, label: "Agenda", path: "/calendar" },
|
{ icon: Calendar, label: "Agenda", path: "/calendar" },
|
||||||
].map((item) => {
|
].map((item) => {
|
||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
|
// Lien wouter : navigation interne sans recharger l'application
|
||||||
return (
|
return (
|
||||||
<a
|
<Link
|
||||||
key={item.path}
|
key={item.path}
|
||||||
href={item.path}
|
href={item.path}
|
||||||
className="flex flex-col items-center p-3 rounded-lg bg-gray-50 hover:bg-gray-100"
|
className="flex flex-col items-center p-3 rounded-lg bg-gray-50 hover:bg-gray-100"
|
||||||
>
|
>
|
||||||
<Icon className="h-6 w-6 text-gray-600 mb-1" />
|
<Icon className="h-6 w-6 text-gray-600 mb-1" />
|
||||||
<span className="text-xs text-gray-600">{item.label}</span>
|
<span className="text-xs text-gray-600">{item.label}</span>
|
||||||
</a>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -70,30 +70,40 @@ const dlcFormSchema = z.object({
|
|||||||
notes: z.string().optional(),
|
notes: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Référence stable tant que la liste n'est pas chargée
|
||||||
|
const NO_PRODUCTS: any[] = [];
|
||||||
|
|
||||||
export default function MobileDlcPage() {
|
export default function MobileDlcPage() {
|
||||||
const { user } = useAuthUnified();
|
const { user } = useAuthUnified();
|
||||||
const { selectedStoreId } = useStore();
|
const { selectedStoreId } = useStore();
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState("");
|
||||||
const [statusFilter, setStatusFilter] = useState("en_cours"); // en_cours, expires_soon, expires, valides
|
const [statusFilter, setStatusFilter] = useState("en_cours"); // en_cours, expires_soon, expires, valides
|
||||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// Recherche appliquée 300 ms après la dernière frappe (filtrage local)
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setDebouncedSearchTerm(searchTerm);
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [searchTerm]);
|
||||||
|
|
||||||
// Data fetching
|
// Data fetching
|
||||||
const { data: dlcProducts = [], isLoading } = useQuery({
|
// Toute la liste du magasin est chargée une seule fois : l'onglet et la
|
||||||
queryKey: ["/api/dlc-products", selectedStoreId, statusFilter, searchTerm],
|
// recherche filtrent localement, sans nouvelle requête (clé propre au mobile,
|
||||||
|
// toujours invalidée par le préfixe "/api/dlc-products" des mutations)
|
||||||
|
const { data: dlcProducts = NO_PRODUCTS, isLoading } = useQuery({
|
||||||
|
queryKey: ["/api/dlc-products", "mobile", selectedStoreId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const params = new URLSearchParams();
|
if (!selectedStoreId) return [];
|
||||||
if (selectedStoreId) params.append("storeId", selectedStoreId.toString());
|
return apiRequest(`/api/dlc-products?storeId=${selectedStoreId}`);
|
||||||
// Simplify fetching everything client side for mobile smoothness or filter server side?
|
|
||||||
// Let's filter client side for better UX given the likely smaller dataset per store
|
|
||||||
if (selectedStoreId) {
|
|
||||||
const res = await fetch(`/api/dlc-products?storeId=${selectedStoreId}`, { credentials: 'include' });
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
},
|
},
|
||||||
enabled: !!selectedStoreId && !!user,
|
enabled: !!selectedStoreId && !!user,
|
||||||
|
staleTime: 2 * 60 * 1000, // 2 minutes, comme sur ordinateur
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: suppliers = [] } = useQuery({
|
const { data: suppliers = [] } = useQuery({
|
||||||
@@ -230,8 +240,8 @@ export default function MobileDlcPage() {
|
|||||||
let filtered = dlcProducts;
|
let filtered = dlcProducts;
|
||||||
|
|
||||||
// Search
|
// Search
|
||||||
if (searchTerm) {
|
if (debouncedSearchTerm) {
|
||||||
const lower = searchTerm.toLowerCase();
|
const lower = debouncedSearchTerm.toLowerCase();
|
||||||
filtered = filtered.filter((p: any) =>
|
filtered = filtered.filter((p: any) =>
|
||||||
p.productName.toLowerCase().includes(lower) ||
|
p.productName.toLowerCase().includes(lower) ||
|
||||||
(p.gencode && p.gencode.includes(lower))
|
(p.gencode && p.gencode.includes(lower))
|
||||||
@@ -254,7 +264,7 @@ export default function MobileDlcPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return filtered.sort((a: any, b: any) => new Date(a.expiryDate).getTime() - new Date(b.expiryDate).getTime());
|
return filtered.sort((a: any, b: any) => new Date(a.expiryDate).getTime() - new Date(b.expiryDate).getTime());
|
||||||
}, [dlcProducts, searchTerm, statusFilter]);
|
}, [dlcProducts, debouncedSearchTerm, statusFilter]);
|
||||||
|
|
||||||
const getDaysBadge = (dateStr: string) => {
|
const getDaysBadge = (dateStr: string) => {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
|
|||||||
@@ -2,16 +2,33 @@
|
|||||||
* MobileApp - Wrapper pour l'application mobile avec StoreProvider
|
* MobileApp - Wrapper pour l'application mobile avec StoreProvider
|
||||||
* Fournit le contexte Store à toutes les pages mobiles
|
* Fournit le contexte Store à toutes les pages mobiles
|
||||||
*/
|
*/
|
||||||
import { ReactNode, useState, useEffect } from "react";
|
import { ReactNode, Suspense, useState, useEffect, useMemo } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
||||||
import { StoreProvider } from "@/contexts/StoreContext";
|
import { StoreProvider } from "@/contexts/StoreContext";
|
||||||
|
import MobileLayout from "./MobileLayout";
|
||||||
import type { Group } from "@shared/schema";
|
import type { Group } from "@shared/schema";
|
||||||
|
|
||||||
interface MobileAppProps {
|
interface MobileAppProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tableau vide stable : évite de recréer la valeur du contexte magasin à chaque
|
||||||
|
// rendu tant que /api/groups n'a pas répondu
|
||||||
|
const EMPTY_STORES: Group[] = [];
|
||||||
|
|
||||||
|
// Affiché pendant le chargement du code d'une page : en-tête et barre de
|
||||||
|
// navigation restent visibles
|
||||||
|
function MobilePageFallback() {
|
||||||
|
return (
|
||||||
|
<MobileLayout>
|
||||||
|
<div className="flex items-center justify-center py-16">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||||
|
</div>
|
||||||
|
</MobileLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function MobileApp({ children }: MobileAppProps) {
|
export default function MobileApp({ children }: MobileAppProps) {
|
||||||
const { user } = useAuthUnified();
|
const { user } = useAuthUnified();
|
||||||
|
|
||||||
@@ -26,7 +43,7 @@ export default function MobileApp({ children }: MobileAppProps) {
|
|||||||
const [storeInitialized, setStoreInitialized] = useState(false);
|
const [storeInitialized, setStoreInitialized] = useState(false);
|
||||||
|
|
||||||
// Fetch stores
|
// Fetch stores
|
||||||
const { data: stores = [] } = useQuery<Group[]>({
|
const { data: stores = EMPTY_STORES } = useQuery<Group[]>({
|
||||||
queryKey: ['/api/groups'],
|
queryKey: ['/api/groups'],
|
||||||
enabled: !!user,
|
enabled: !!user,
|
||||||
});
|
});
|
||||||
@@ -52,7 +69,9 @@ export default function MobileApp({ children }: MobileAppProps) {
|
|||||||
}
|
}
|
||||||
}, [selectedStoreId]);
|
}, [selectedStoreId]);
|
||||||
|
|
||||||
const storeContextValue = {
|
// Valeur du contexte mémorisée : les consommateurs de useStore() ne sont
|
||||||
|
// re-rendus que lorsqu'une de ces valeurs change réellement
|
||||||
|
const storeContextValue = useMemo(() => ({
|
||||||
selectedStoreId,
|
selectedStoreId,
|
||||||
setSelectedStoreId,
|
setSelectedStoreId,
|
||||||
stores,
|
stores,
|
||||||
@@ -61,11 +80,13 @@ export default function MobileApp({ children }: MobileAppProps) {
|
|||||||
mobileMenuOpen,
|
mobileMenuOpen,
|
||||||
setMobileMenuOpen,
|
setMobileMenuOpen,
|
||||||
storeInitialized
|
storeInitialized
|
||||||
};
|
}), [selectedStoreId, stores, sidebarCollapsed, mobileMenuOpen, storeInitialized]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StoreProvider value={storeContextValue}>
|
<StoreProvider value={storeContextValue}>
|
||||||
{children}
|
<Suspense fallback={<MobilePageFallback />}>
|
||||||
|
{children}
|
||||||
|
</Suspense>
|
||||||
</StoreProvider>
|
</StoreProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,137 +1,63 @@
|
|||||||
/**
|
/**
|
||||||
* MobileBottomNav - Navigation bottom fixe pour l'application mobile
|
* MobileBottomNav - Barre de navigation fixe en bas de l'écran (téléphone)
|
||||||
* 5 boutons: Accueil, Commandes, Livraisons, Tâches, Plus
|
* 4 raccourcis tirés de la configuration du menu (selon le rôle) + « Menu »
|
||||||
*/
|
*/
|
||||||
import { Link, useLocation } from "wouter";
|
import { Link, useLocation } from "wouter";
|
||||||
import { useState } from "react";
|
import { Menu } from "lucide-react";
|
||||||
import {
|
|
||||||
Home,
|
|
||||||
Package,
|
|
||||||
Truck,
|
|
||||||
CheckSquare,
|
|
||||||
MoreHorizontal,
|
|
||||||
Calendar,
|
|
||||||
Megaphone,
|
|
||||||
ShoppingCart,
|
|
||||||
Clock,
|
|
||||||
Wrench,
|
|
||||||
Receipt
|
|
||||||
} from "lucide-react";
|
|
||||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
|
||||||
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
||||||
|
import { getMobileShortcuts, isNavItemActive } from "@/lib/navigation";
|
||||||
|
|
||||||
interface NavItem {
|
interface MobileBottomNavProps {
|
||||||
path: string;
|
menuOpen: boolean;
|
||||||
label: string;
|
onOpenMenu: () => void;
|
||||||
icon: React.ComponentType<{ className?: string }>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const primaryItems: NavItem[] = [
|
export default function MobileBottomNav({ menuOpen, onOpenMenu }: MobileBottomNavProps) {
|
||||||
{ path: "/dashboard", label: "Accueil", icon: Home },
|
|
||||||
{ path: "/orders", label: "Commandes", icon: Package },
|
|
||||||
{ path: "/deliveries", label: "Livraisons", icon: Truck },
|
|
||||||
{ path: "/tasks", label: "Tâches", icon: CheckSquare },
|
|
||||||
];
|
|
||||||
|
|
||||||
const moreItems: NavItem[] = [
|
|
||||||
{ path: "/calendar", label: "Calendrier", icon: Calendar },
|
|
||||||
{ path: "/publicities", label: "Publicités", icon: Megaphone },
|
|
||||||
{ path: "/customer-orders", label: "Cmd Client", icon: ShoppingCart },
|
|
||||||
{ path: "/dlc", label: "DLC", icon: Clock },
|
|
||||||
{ path: "/sav", label: "SAV", icon: Wrench },
|
|
||||||
{ path: "/avoirs", label: "Avoirs", icon: Receipt },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function MobileBottomNav() {
|
|
||||||
const [location] = useLocation();
|
const [location] = useLocation();
|
||||||
const { user } = useAuthUnified();
|
const { user } = useAuthUnified();
|
||||||
const [moreOpen, setMoreOpen] = useState(false);
|
const shortcuts = getMobileShortcuts(user?.role);
|
||||||
|
|
||||||
const isActive = (path: string) => {
|
// « Menu » est mis en avant quand la page affichée n'est pas un raccourci
|
||||||
if (path === "/dashboard" && (location === "/" || location === "/dashboard")) return true;
|
const menuActive = menuOpen || !shortcuts.some((item) => isNavItemActive(item.path, location));
|
||||||
return location.startsWith(path);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check if any "more" item is active
|
const itemClasses = (active: boolean) =>
|
||||||
const isMoreActive = moreItems.some(item => isActive(item.path));
|
`flex flex-1 flex-col items-center justify-center gap-0.5 min-w-0 h-full px-1 text-xs font-medium leading-tight text-center ${active ? 'text-blue-700' : 'text-gray-600'}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<nav
|
||||||
{/* Navigation bottom fixe */}
|
className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 z-40"
|
||||||
<nav
|
style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}
|
||||||
className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 z-50"
|
aria-label="Navigation principale"
|
||||||
style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}
|
>
|
||||||
>
|
<div className="flex items-stretch h-16">
|
||||||
<div className="flex items-center justify-around h-16">
|
{shortcuts.map((item) => {
|
||||||
{/* Items principaux */}
|
const Icon = item.icon;
|
||||||
{primaryItems.map((item) => {
|
const active = isNavItemActive(item.path, location);
|
||||||
const Icon = item.icon;
|
|
||||||
const active = isActive(item.path);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link key={item.path} href={item.path}>
|
<Link
|
||||||
<div className={`flex flex-col items-center justify-center min-w-[60px] py-1 ${active ? 'text-blue-600' : 'text-gray-500'
|
key={item.path}
|
||||||
}`}>
|
href={item.path}
|
||||||
<Icon className={`h-5 w-5 ${active ? 'text-blue-600' : 'text-gray-500'}`} />
|
aria-current={active ? "page" : undefined}
|
||||||
<span className={`text-[10px] mt-0.5 font-medium ${active ? 'text-blue-600' : 'text-gray-500'
|
className={itemClasses(active)}
|
||||||
}`}>
|
>
|
||||||
{item.label}
|
<Icon className="h-5 w-5 shrink-0" aria-hidden="true" />
|
||||||
</span>
|
<span className="line-clamp-2">{item.label}</span>
|
||||||
</div>
|
</Link>
|
||||||
</Link>
|
);
|
||||||
);
|
})}
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Bouton "Plus" */}
|
<button
|
||||||
<div
|
type="button"
|
||||||
className={`flex flex-col items-center justify-center min-w-[60px] py-1 cursor-pointer ${isMoreActive || moreOpen ? 'text-blue-600' : 'text-gray-500'
|
onClick={onOpenMenu}
|
||||||
}`}
|
aria-haspopup="dialog"
|
||||||
onClick={() => setMoreOpen(true)}
|
aria-expanded={menuOpen}
|
||||||
>
|
className={itemClasses(menuActive)}
|
||||||
<MoreHorizontal className={`h-5 w-5 ${isMoreActive || moreOpen ? 'text-blue-600' : 'text-gray-500'
|
>
|
||||||
}`} />
|
<Menu className="h-5 w-5 shrink-0" aria-hidden="true" />
|
||||||
<span className={`text-[10px] mt-0.5 font-medium ${isMoreActive || moreOpen ? 'text-blue-600' : 'text-gray-500'
|
<span>Menu</span>
|
||||||
}`}>
|
</button>
|
||||||
Plus
|
</div>
|
||||||
</span>
|
</nav>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{/* Sheet "Plus" */}
|
|
||||||
<Sheet open={moreOpen} onOpenChange={setMoreOpen}>
|
|
||||||
<SheetContent side="bottom" className="h-auto max-h-[50vh] rounded-t-xl">
|
|
||||||
<SheetHeader className="pb-4">
|
|
||||||
<SheetTitle>Autres pages</SheetTitle>
|
|
||||||
</SheetHeader>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-3 pb-6">
|
|
||||||
{moreItems.map((item) => {
|
|
||||||
const Icon = item.icon;
|
|
||||||
const active = isActive(item.path);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={item.path}
|
|
||||||
href={item.path}
|
|
||||||
onClick={() => setMoreOpen(false)}
|
|
||||||
>
|
|
||||||
<div className={`flex flex-col items-center justify-center p-4 rounded-xl min-h-[80px] ${active
|
|
||||||
? 'bg-blue-100 text-blue-700'
|
|
||||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
|
||||||
}`}>
|
|
||||||
<Icon className={`h-6 w-6 ${active ? 'text-blue-600' : 'text-gray-600'}`} />
|
|
||||||
<span className={`text-xs mt-2 font-medium text-center ${active ? 'text-blue-700' : 'text-gray-700'
|
|
||||||
}`}>
|
|
||||||
{item.label}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,15 +1,18 @@
|
|||||||
/**
|
/**
|
||||||
* MobileLayout - Layout dédié pour l'application mobile
|
* MobileLayout - Cadre de l'application sur téléphone
|
||||||
* Affiche le magasin sélectionné de manière visible et une navigation bottom
|
* En-tête (titre de la page + magasin), contenu défilant, barre du bas et
|
||||||
|
* menu complet, tous tirés de la même configuration que le menu PC
|
||||||
* Note: Le StoreProvider est fourni par MobileApp au niveau supérieur
|
* Note: Le StoreProvider est fourni par MobileApp au niveau supérieur
|
||||||
*/
|
*/
|
||||||
import { ReactNode, useState } from "react";
|
import { ReactNode, useEffect, useState } from "react";
|
||||||
import { Menu, Store, ChevronDown, LogOut } from "lucide-react";
|
import { Link, useLocation } from "wouter";
|
||||||
|
import { Boxes, ChevronDown, LogOut } from "lucide-react";
|
||||||
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
||||||
import { useStore } from "@/contexts/StoreContext";
|
import { useStore } from "@/contexts/StoreContext";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { getNavItemForPath, getNavSections, getRoleLabel, isNavItemActive } from "@/lib/navigation";
|
||||||
import MobileBottomNav from "./MobileBottomNav";
|
import MobileBottomNav from "./MobileBottomNav";
|
||||||
import type { Group } from "@shared/schema";
|
import type { Group } from "@shared/schema";
|
||||||
|
|
||||||
@@ -20,10 +23,16 @@ interface MobileLayoutProps {
|
|||||||
|
|
||||||
export default function MobileLayout({ children, title }: MobileLayoutProps) {
|
export default function MobileLayout({ children, title }: MobileLayoutProps) {
|
||||||
const { user } = useAuthUnified();
|
const { user } = useAuthUnified();
|
||||||
|
const [location] = useLocation();
|
||||||
const { selectedStoreId, setSelectedStoreId, stores } = useStore();
|
const { selectedStoreId, setSelectedStoreId, stores } = useStore();
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
|
||||||
// Get selected store name
|
// Titre de l'onglet du navigateur tiré du menu, comme sur PC
|
||||||
|
useEffect(() => {
|
||||||
|
const navItem = getNavItemForPath(location);
|
||||||
|
document.title = navItem ? `${navItem.label} — LogiFlow` : "LogiFlow";
|
||||||
|
}, [location]);
|
||||||
|
|
||||||
const selectedStore = stores?.find((s: Group) => s.id === selectedStoreId);
|
const selectedStore = stores?.find((s: Group) => s.id === selectedStoreId);
|
||||||
const storeName = selectedStore?.name || "Tous les magasins";
|
const storeName = selectedStore?.name || "Tous les magasins";
|
||||||
|
|
||||||
@@ -47,6 +56,11 @@ export default function MobileLayout({ children, title }: MobileLayoutProps) {
|
|||||||
setSelectedStoreId(newStoreId);
|
setSelectedStoreId(newStoreId);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const sections = getNavSections(user?.role);
|
||||||
|
const displayName = user && (user.firstName || user.lastName
|
||||||
|
? `${user.firstName || ''} ${user.lastName || ''}`.trim()
|
||||||
|
: user.username);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="bg-gray-50 flex flex-col"
|
className="bg-gray-50 flex flex-col"
|
||||||
@@ -62,46 +76,38 @@ export default function MobileLayout({ children, title }: MobileLayoutProps) {
|
|||||||
bottom: 0
|
bottom: 0
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Header fixe */}
|
{/* En-tête : logo + magasin actif (le titre est porté par chaque page) */}
|
||||||
<header className="bg-white border-b border-gray-200 sticky top-0 z-50">
|
<header className="bg-white border-b border-gray-200 sticky top-0 z-40">
|
||||||
<div className="flex items-center justify-between h-14 px-3">
|
<div className="flex items-center justify-between gap-3 h-14 px-4">
|
||||||
{/* Menu hamburger */}
|
<Link href="/" className="flex items-center gap-2 shrink-0">
|
||||||
<Button
|
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-blue-600">
|
||||||
variant="ghost"
|
<Boxes className="h-4 w-4 text-white" aria-hidden="true" />
|
||||||
size="sm"
|
</span>
|
||||||
onClick={() => setMenuOpen(true)}
|
|
||||||
className="h-10 w-10 p-0"
|
|
||||||
>
|
|
||||||
<Menu className="h-5 w-5" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* Logo */}
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Store className="h-5 w-5 text-blue-600" />
|
|
||||||
<span className="font-semibold text-gray-900">LogiFlow</span>
|
<span className="font-semibold text-gray-900">LogiFlow</span>
|
||||||
</div>
|
</Link>
|
||||||
|
|
||||||
{/* Magasin sélectionné - bien visible */}
|
<button
|
||||||
<div
|
type="button"
|
||||||
className="flex items-center gap-1 bg-blue-50 px-2 py-1 rounded-lg cursor-pointer"
|
className="flex items-center gap-1 bg-blue-50 px-2.5 py-1.5 rounded-lg min-w-0 shrink"
|
||||||
onClick={() => setMenuOpen(true)}
|
onClick={() => setMenuOpen(true)}
|
||||||
|
aria-label={`Magasin : ${storeName}. Ouvrir le menu`}
|
||||||
>
|
>
|
||||||
<span className="text-xs font-medium text-blue-700 truncate max-w-[80px]">
|
<span className="text-sm font-medium text-blue-700 truncate max-w-[140px]">
|
||||||
{storeName}
|
{storeName}
|
||||||
</span>
|
</span>
|
||||||
<ChevronDown className="h-3 w-3 text-blue-600" />
|
<ChevronDown className="h-4 w-4 text-blue-600 shrink-0" aria-hidden="true" />
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Titre de page optionnel */}
|
{/* Titre de page optionnel */}
|
||||||
{title && (
|
{title && (
|
||||||
<div className="px-3 pb-2">
|
<div className="px-4 pb-2">
|
||||||
<h1 className="text-lg font-bold text-gray-900">{title}</h1>
|
<h1 className="text-lg font-bold text-gray-900">{title}</h1>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Contenu principal - scrollable avec espace pour navbar */}
|
{/* Contenu principal - défilant, avec la place de la barre du bas */}
|
||||||
<main
|
<main
|
||||||
className="flex-1 overflow-x-hidden"
|
className="flex-1 overflow-x-hidden"
|
||||||
style={{
|
style={{
|
||||||
@@ -113,35 +119,50 @@ export default function MobileLayout({ children, title }: MobileLayoutProps) {
|
|||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Navigation bottom */}
|
<MobileBottomNav menuOpen={menuOpen} onOpenMenu={() => setMenuOpen(true)} />
|
||||||
<MobileBottomNav />
|
|
||||||
|
|
||||||
{/* Menu latéral (Sheet) */}
|
{/* Menu complet : tous les modules autorisés, magasin, profil */}
|
||||||
<Sheet open={menuOpen} onOpenChange={setMenuOpen}>
|
<Sheet open={menuOpen} onOpenChange={setMenuOpen}>
|
||||||
<SheetContent side="left" className="w-[280px] p-0">
|
<SheetContent side="bottom" className="max-h-[85vh] overflow-y-auto rounded-t-xl p-0">
|
||||||
<SheetHeader className="p-4 border-b">
|
<SheetHeader className="px-4 pt-4 pb-2 text-left">
|
||||||
<SheetTitle className="flex items-center gap-2">
|
<SheetTitle>Menu</SheetTitle>
|
||||||
<Store className="h-5 w-5 text-blue-600" />
|
|
||||||
LogiFlow
|
|
||||||
</SheetTitle>
|
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
<div className="p-4 space-y-6">
|
<nav className="px-4 space-y-4" aria-label="Menu principal">
|
||||||
{/* Utilisateur */}
|
{sections.map((section) => (
|
||||||
{user && (
|
<div key={section.title ?? 'accueil'}>
|
||||||
<div className="bg-gray-50 rounded-lg p-3">
|
{section.title && (
|
||||||
<p className="font-medium text-gray-900">
|
<h2 className="mb-2 text-xs font-semibold text-gray-500">{section.title}</h2>
|
||||||
{user.firstName} {user.lastName}
|
)}
|
||||||
</p>
|
<div className="grid grid-cols-3 gap-2">
|
||||||
<p className="text-sm text-gray-500 capitalize">{user.role}</p>
|
{section.items.map((item) => {
|
||||||
</div>
|
const Icon = item.icon;
|
||||||
)}
|
const active = isNavItemActive(item.path, location);
|
||||||
|
|
||||||
{/* Sélecteur de magasin */}
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.path}
|
||||||
|
href={item.path}
|
||||||
|
onClick={() => setMenuOpen(false)}
|
||||||
|
aria-current={active ? "page" : undefined}
|
||||||
|
className={`flex flex-col items-center justify-center gap-1.5 rounded-xl p-2 min-h-[76px] text-center text-xs font-medium leading-tight ${active
|
||||||
|
? 'bg-blue-50 text-blue-700 ring-1 ring-blue-200'
|
||||||
|
: 'bg-gray-100 text-gray-800 active:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className={`h-6 w-6 ${active ? 'text-blue-700' : 'text-gray-600'}`} aria-hidden="true" />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="mt-4 border-t border-gray-200 p-4 space-y-4" style={{ paddingBottom: 'calc(1rem + env(safe-area-inset-bottom))' }}>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-gray-700">
|
<label className="text-sm font-medium text-gray-700">Magasin</label>
|
||||||
Magasin
|
|
||||||
</label>
|
|
||||||
<Select
|
<Select
|
||||||
value={selectedStoreId?.toString() || (user?.role === 'admin' ? "all" : "")}
|
value={selectedStoreId?.toString() || (user?.role === 'admin' ? "all" : "")}
|
||||||
onValueChange={handleStoreChange}
|
onValueChange={handleStoreChange}
|
||||||
@@ -163,7 +184,7 @@ export default function MobileLayout({ children, title }: MobileLayoutProps) {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div
|
<div
|
||||||
className="w-3 h-3 rounded-full"
|
className="w-3 h-3 rounded-full"
|
||||||
style={{ backgroundColor: store.color || '#gray' }}
|
style={{ backgroundColor: store.color || '#9ca3af' }}
|
||||||
/>
|
/>
|
||||||
{store.name}
|
{store.name}
|
||||||
</div>
|
</div>
|
||||||
@@ -173,15 +194,22 @@ export default function MobileLayout({ children, title }: MobileLayoutProps) {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Déconnexion */}
|
{user && (
|
||||||
<Button
|
<div className="flex items-center justify-between gap-3">
|
||||||
variant="outline"
|
<div className="min-w-0">
|
||||||
className="w-full h-12 text-red-600 border-red-200 hover:bg-red-50"
|
<p className="font-medium text-gray-900 truncate">{displayName}</p>
|
||||||
onClick={handleLogout}
|
<p className="text-sm text-gray-600">{getRoleLabel(user.role)}</p>
|
||||||
>
|
</div>
|
||||||
<LogOut className="h-4 w-4 mr-2" />
|
<Button
|
||||||
Déconnexion
|
variant="outline"
|
||||||
</Button>
|
className="h-11 shrink-0 text-red-600 border-red-200 hover:bg-red-50"
|
||||||
|
onClick={handleLogout}
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4 mr-2" />
|
||||||
|
Déconnexion
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient, useMutation, keepPreviousData } from "@tanstack/react-query";
|
||||||
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
||||||
import { useStore } from "@/contexts/StoreContext";
|
import { useStore } from "@/contexts/StoreContext";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
@@ -32,9 +32,13 @@ type TaskWithRelations = Task & {
|
|||||||
isFutureTask?: boolean;
|
isFutureTask?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Référence stable tant que la liste n'est pas chargée (évite de recalculer les filtres)
|
||||||
|
const NO_TASKS: TaskWithRelations[] = [];
|
||||||
|
|
||||||
// Mobile Task Form Component
|
// Mobile Task Form Component
|
||||||
function MobileTaskForm({ task, onClose, selectedStoreId, user }: any) {
|
function MobileTaskForm({ task, onClose, selectedStoreId, user }: any) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
@@ -84,7 +88,9 @@ function MobileTaskForm({ task, onClose, selectedStoreId, user }: any) {
|
|||||||
title: "Succès",
|
title: "Succès",
|
||||||
description: task ? "Tâche modifiée" : "Tâche créée",
|
description: task ? "Tâche modifiée" : "Tâche créée",
|
||||||
});
|
});
|
||||||
window.location.reload();
|
// Rechargement ciblé de la liste (plus de rechargement complet de la page)
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["/api/tasks"] });
|
||||||
|
onClose();
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({ title: "Erreur", description: "Erreur lors de l'opération", variant: "destructive" });
|
toast({ title: "Erreur", description: "Erreur lors de l'opération", variant: "destructive" });
|
||||||
@@ -291,9 +297,11 @@ export default function MobileTasksPage() {
|
|||||||
const [editingTask, setEditingTask] = useState<TaskWithRelations | null>(null);
|
const [editingTask, setEditingTask] = useState<TaskWithRelations | null>(null);
|
||||||
const [deletingTask, setDeletingTask] = useState<TaskWithRelations | null>(null);
|
const [deletingTask, setDeletingTask] = useState<TaskWithRelations | null>(null);
|
||||||
|
|
||||||
|
const tasksQueryKey = ["/api/tasks", selectedStoreId];
|
||||||
|
|
||||||
// Fetch tasks
|
// Fetch tasks
|
||||||
const { data: tasks = [], isLoading } = useQuery({
|
const { data: tasks = NO_TASKS, isLoading, isPlaceholderData } = useQuery({
|
||||||
queryKey: ["/api/tasks", selectedStoreId],
|
queryKey: tasksQueryKey,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (selectedStoreId) params.append('storeId', selectedStoreId.toString());
|
if (selectedStoreId) params.append('storeId', selectedStoreId.toString());
|
||||||
@@ -305,55 +313,113 @@ export default function MobileTasksPage() {
|
|||||||
return Array.isArray(data) ? data.filter((t: any) => t && t.id) : [];
|
return Array.isArray(data) ? data.filter((t: any) => t && t.id) : [];
|
||||||
},
|
},
|
||||||
enabled: !!user,
|
enabled: !!user,
|
||||||
|
// Au changement de magasin, la liste précédente reste affichée (estompée et
|
||||||
|
// non cliquable, avec un indicateur) au lieu d'un spinner plein écran
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Filter tasks
|
// Filter tasks (recalculé seulement quand la liste, l'onglet ou la recherche change)
|
||||||
const filteredTasks = tasks
|
const filteredTasks = useMemo(() => {
|
||||||
.filter((task: TaskWithRelations) => {
|
const search = searchTerm.toLowerCase();
|
||||||
// Tab filter
|
return tasks
|
||||||
if (activeTab === 'pending' && task.status === 'completed') return false;
|
.filter((task: TaskWithRelations) => {
|
||||||
if (activeTab === 'completed' && task.status !== 'completed') return false;
|
// Tab filter
|
||||||
|
if (activeTab === 'pending' && task.status === 'completed') return false;
|
||||||
|
if (activeTab === 'completed' && task.status !== 'completed') return false;
|
||||||
|
|
||||||
// Search filter
|
// Search filter
|
||||||
if (searchTerm && !task.title.toLowerCase().includes(searchTerm.toLowerCase())) {
|
if (search && !task.title.toLowerCase().includes(search)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
})
|
})
|
||||||
.sort((a: TaskWithRelations, b: TaskWithRelations) => {
|
.sort((a: TaskWithRelations, b: TaskWithRelations) => {
|
||||||
// Pending first, then by priority
|
// Pending first, then by priority
|
||||||
if (a.status !== b.status) return a.status === 'pending' ? -1 : 1;
|
if (a.status !== b.status) return a.status === 'pending' ? -1 : 1;
|
||||||
const priorityOrder = { high: 3, medium: 2, low: 1 };
|
const priorityOrder = { high: 3, medium: 2, low: 1 };
|
||||||
return (priorityOrder[b.priority as keyof typeof priorityOrder] || 2) -
|
return (priorityOrder[b.priority as keyof typeof priorityOrder] || 2) -
|
||||||
(priorityOrder[a.priority as keyof typeof priorityOrder] || 2);
|
(priorityOrder[a.priority as keyof typeof priorityOrder] || 2);
|
||||||
});
|
});
|
||||||
|
}, [tasks, activeTab, searchTerm]);
|
||||||
|
|
||||||
// Handlers
|
// Mise à jour optimiste de la liste affichée ; renvoie l'état précédent pour
|
||||||
const handleComplete = async (taskId: number) => {
|
// pouvoir le restaurer si le serveur refuse l'opération
|
||||||
try {
|
const updateCachedTasks = async (update: (list: TaskWithRelations[]) => TaskWithRelations[]) => {
|
||||||
await fetch(`/api/tasks/${taskId}/complete`, {
|
const queryKey = tasksQueryKey;
|
||||||
|
await queryClient.cancelQueries({ queryKey });
|
||||||
|
const previousTasks = queryClient.getQueryData<TaskWithRelations[]>(queryKey);
|
||||||
|
if (Array.isArray(previousTasks)) {
|
||||||
|
queryClient.setQueryData<TaskWithRelations[]>(queryKey, update(previousTasks));
|
||||||
|
}
|
||||||
|
return { queryKey, previousTasks };
|
||||||
|
};
|
||||||
|
|
||||||
|
const restoreCachedTasks = (context?: { queryKey: unknown[]; previousTasks?: TaskWithRelations[] }) => {
|
||||||
|
if (context?.previousTasks) {
|
||||||
|
queryClient.setQueryData(context.queryKey, context.previousTasks);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Terminer : la carte passe tout de suite en « terminée », puis la liste est
|
||||||
|
// rechargée depuis le serveur ; le succès n'est annoncé que si l'API l'accepte
|
||||||
|
const completeMutation = useMutation({
|
||||||
|
mutationFn: async (taskId: number) => {
|
||||||
|
const response = await fetch(`/api/tasks/${taskId}/complete`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify({}),
|
||||||
});
|
});
|
||||||
queryClient.invalidateQueries({ queryKey: ["/api/tasks"] });
|
if (!response.ok) throw new Error(`Erreur ${response.status}`);
|
||||||
|
},
|
||||||
|
onMutate: (taskId: number) => {
|
||||||
|
const now = new Date();
|
||||||
|
return updateCachedTasks((list) => list.map((task) =>
|
||||||
|
task.id === taskId
|
||||||
|
? { ...task, status: 'completed', completedAt: now, completedBy: user?.id ?? task.completedBy, updatedAt: now }
|
||||||
|
: task
|
||||||
|
));
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
toast({ title: "✅ Tâche terminée" });
|
toast({ title: "✅ Tâche terminée" });
|
||||||
} catch (error) {
|
},
|
||||||
|
onError: (_error, _taskId, context) => {
|
||||||
|
restoreCachedTasks(context);
|
||||||
toast({ title: "Erreur", variant: "destructive" });
|
toast({ title: "Erreur", variant: "destructive" });
|
||||||
}
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["/api/tasks"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Supprimer : la carte disparaît et la feuille se ferme tout de suite
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: async (taskId: number) => {
|
||||||
|
const response = await fetch(`/api/tasks/${taskId}`, { method: 'DELETE', credentials: 'include' });
|
||||||
|
if (!response.ok) throw new Error(`Erreur ${response.status}`);
|
||||||
|
},
|
||||||
|
onMutate: (taskId: number) => updateCachedTasks((list) => list.filter((task) => task.id !== taskId)),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({ title: "🗑️ Tâche supprimée" });
|
||||||
|
},
|
||||||
|
onError: (_error, _taskId, context) => {
|
||||||
|
restoreCachedTasks(context);
|
||||||
|
toast({ title: "Erreur", variant: "destructive" });
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["/api/tasks"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handlers
|
||||||
|
const handleComplete = (taskId: number) => {
|
||||||
|
completeMutation.mutate(taskId);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = () => {
|
||||||
if (!deletingTask) return;
|
if (!deletingTask) return;
|
||||||
try {
|
deleteMutation.mutate(deletingTask.id);
|
||||||
await fetch(`/api/tasks/${deletingTask.id}`, { method: 'DELETE', credentials: 'include' });
|
setDeletingTask(null);
|
||||||
queryClient.invalidateQueries({ queryKey: ["/api/tasks"] });
|
|
||||||
toast({ title: "🗑️ Tâche supprimée" });
|
|
||||||
setDeletingTask(null);
|
|
||||||
} catch (error) {
|
|
||||||
toast({ title: "Erreur", variant: "destructive" });
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const canEdit = user?.role === 'admin' || user?.role === 'manager' || user?.role === 'directeur';
|
const canEdit = user?.role === 'admin' || user?.role === 'manager' || user?.role === 'directeur';
|
||||||
@@ -378,6 +444,12 @@ export default function MobileTasksPage() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<ListTodo className="w-5 h-5 text-blue-600" />
|
<ListTodo className="w-5 h-5 text-blue-600" />
|
||||||
<h1 className="text-lg font-bold">Tâches</h1>
|
<h1 className="text-lg font-bold">Tâches</h1>
|
||||||
|
{isPlaceholderData && (
|
||||||
|
<span className="inline-flex items-center gap-1 text-xs text-gray-500" role="status">
|
||||||
|
<span className="animate-spin rounded-full h-3 w-3 border-b-2 border-blue-600" />
|
||||||
|
Mise à jour…
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="secondary">{filteredTasks.length}</Badge>
|
<Badge variant="secondary">{filteredTasks.length}</Badge>
|
||||||
</div>
|
</div>
|
||||||
@@ -423,8 +495,12 @@ export default function MobileTasksPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Task List */}
|
{/* Task List (liste du magasin précédent estompée et non cliquable pendant le chargement) */}
|
||||||
<div className="px-3 py-2 overflow-hidden" style={{ maxWidth: '100%' }}>
|
<div
|
||||||
|
className={`px-3 py-2 overflow-hidden ${isPlaceholderData ? 'opacity-60 pointer-events-none transition-opacity' : ''}`}
|
||||||
|
style={{ maxWidth: '100%' }}
|
||||||
|
aria-busy={isPlaceholderData}
|
||||||
|
>
|
||||||
{filteredTasks.length === 0 ? (
|
{filteredTasks.length === 0 ? (
|
||||||
<div className="text-center py-12 text-gray-500">
|
<div className="text-center py-12 text-gray-500">
|
||||||
<ListTodo className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
<ListTodo className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
# Fichier de configuration Docker Compose pour le développement et la production
|
|
||||||
# Utilise les variables d'environnement pour la configuration
|
|
||||||
|
|
||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
|
||||||
app:
|
|
||||||
volumes:
|
|
||||||
# Monter les scripts de migration
|
|
||||||
- ./scripts:/app/scripts:ro
|
|
||||||
environment:
|
|
||||||
# Forcer l'exécution des migrations au démarrage
|
|
||||||
- AUTO_MIGRATE=true
|
|
||||||
# Variables de debug
|
|
||||||
- DEBUG_MIGRATIONS=true
|
|
||||||
+20
-10
@@ -1,5 +1,22 @@
|
|||||||
version: '3.8'
|
version: '3.8'
|
||||||
|
|
||||||
|
# logiflow : serveur Rust (server-rs/), qui sert l'API et l'interface, applique
|
||||||
|
# les migrations au démarrage et lance les tâches de fond (sauvegardes, purge).
|
||||||
|
|
||||||
|
x-logiflow-env: &logiflow-env
|
||||||
|
NODE_ENV: production
|
||||||
|
DATABASE_URL: postgresql://logiflow_admin:LogiFlow2025!@logiflow-db:5432/logiflow_db
|
||||||
|
SESSION_SECRET: LogiFlow_Super_Secret_Session_Key_2025_Production
|
||||||
|
USE_LOCAL_AUTH: "true"
|
||||||
|
POSTGRES_USER: logiflow_admin
|
||||||
|
POSTGRES_PASSWORD: LogiFlow2025!
|
||||||
|
POSTGRES_DB: logiflow_db
|
||||||
|
BACKUP_DIR: /app/backups
|
||||||
|
# Clé(s) de l'API externe de rapprochement, lues depuis le .env de l'hôte (vide = API désactivée)
|
||||||
|
EXTERNAL_API_KEYS: ${EXTERNAL_API_KEYS:-}
|
||||||
|
# Origines acceptées en plus de l'adresse du site pour les écritures (décision R4)
|
||||||
|
ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-}
|
||||||
|
|
||||||
services:
|
services:
|
||||||
logiflow:
|
logiflow:
|
||||||
build:
|
build:
|
||||||
@@ -8,15 +25,9 @@ services:
|
|||||||
target: production
|
target: production
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
<<: *logiflow-env
|
||||||
DATABASE_URL: postgresql://logiflow_admin:LogiFlow2025!@logiflow-db:5432/logiflow_db
|
|
||||||
SESSION_SECRET: LogiFlow_Super_Secret_Session_Key_2025_Production
|
|
||||||
PORT: 3000
|
PORT: 3000
|
||||||
USE_LOCAL_AUTH: "true"
|
RUST_LOG: info
|
||||||
POSTGRES_USER: logiflow_admin
|
|
||||||
POSTGRES_PASSWORD: LogiFlow2025!
|
|
||||||
POSTGRES_DB: logiflow_db
|
|
||||||
BACKUP_DIR: /app/backups
|
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
networks:
|
networks:
|
||||||
@@ -25,14 +36,13 @@ services:
|
|||||||
logiflow-db:
|
logiflow-db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
volumes:
|
volumes:
|
||||||
- ./migrations:/app/migrations
|
|
||||||
- backup_data:/app/backups
|
- backup_data:/app/backups
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/api/health"]
|
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/api/health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 40s
|
start_period: 10s
|
||||||
|
|
||||||
logiflow-db:
|
logiflow-db:
|
||||||
image: postgres:15-alpine
|
image: postgres:15-alpine
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
# API externe — Rapprochement BL / factures
|
||||||
|
|
||||||
|
Permet à un outil tiers (logiciel comptable, n8n, script…) de lire les magasins,
|
||||||
|
les fournisseurs et les livraisons livrées avec leur numéro de BL, puis d'écrire
|
||||||
|
la référence et le montant de la facture, comme dans la page **Rapprochement**.
|
||||||
|
|
||||||
|
Code : `server/externalApi.ts`. Préfixe : `/api/ext/v1`.
|
||||||
|
|
||||||
|
## Activation et authentification
|
||||||
|
|
||||||
|
**Depuis LogiFlow (recommandé)** : *Paramètres > API externe* (admin). On y
|
||||||
|
crée une clé par outil (nommée, ex. « n8n comptabilité »), affichée une seule
|
||||||
|
fois à sa création, avec sa date de dernière utilisation ; une clé se révoque
|
||||||
|
d'un clic, sans redémarrage. Seule l'empreinte SHA-256 de la clé est stockée
|
||||||
|
(table `external_api_keys`). L'onglet s'ouvre directement par
|
||||||
|
`/utilities?tab=api`.
|
||||||
|
|
||||||
|
**Par l'environnement (optionnel)** : des clés peuvent aussi être définies,
|
||||||
|
séparées par des virgules, puis le serveur redémarré :
|
||||||
|
|
||||||
|
```
|
||||||
|
EXTERNAL_API_KEYS=3f9c…a1,7b20…e4
|
||||||
|
```
|
||||||
|
|
||||||
|
Générer une clé :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucune clé active (ni dans Paramètres, ni dans l'environnement) : l'API répond
|
||||||
|
`503` (désactivée).
|
||||||
|
|
||||||
|
Chaque requête envoie la clé dans l'un de ces en-têtes :
|
||||||
|
|
||||||
|
```
|
||||||
|
X-API-Key: <clé>
|
||||||
|
Authorization: Bearer <clé>
|
||||||
|
```
|
||||||
|
|
||||||
|
Clé absente ou fausse : `401`. Une clé donne accès à **tous les magasins** ;
|
||||||
|
pour révoquer un outil, retirer sa clé de la liste et redémarrer.
|
||||||
|
Exposer l'API uniquement en HTTPS (la clé circule dans chaque requête).
|
||||||
|
|
||||||
|
## Points d'accès
|
||||||
|
|
||||||
|
### `GET /stores` — magasins
|
||||||
|
|
||||||
|
```json
|
||||||
|
[{ "id": 1, "name": "Magasin Nancy" }]
|
||||||
|
```
|
||||||
|
|
||||||
|
### `GET /suppliers` — fournisseurs
|
||||||
|
|
||||||
|
```json
|
||||||
|
[{ "id": 4, "name": "Lactalis", "code": "LAC01", "paymentMethod": "Virement", "automaticReconciliation": false }]
|
||||||
|
```
|
||||||
|
|
||||||
|
`code` = code fournisseur (`codefou`).
|
||||||
|
|
||||||
|
### `GET /deliveries` — livraisons à rapprocher
|
||||||
|
|
||||||
|
Seules les livraisons **livrées** sont renvoyées (même périmètre que la page
|
||||||
|
Rapprochement), de la plus récente à la plus ancienne.
|
||||||
|
|
||||||
|
| Paramètre | Exemple | Effet |
|
||||||
|
|--------------|---------------|------------------------------------------------------------|
|
||||||
|
| `storeId` | `1` | Un seul magasin |
|
||||||
|
| `supplierId` | `4` | Un seul fournisseur |
|
||||||
|
| `blNumber` | `BL-4567` | N° de BL exact (casse et espaces de bord ignorés) |
|
||||||
|
| `reconciled` | `false` | `true` = rapprochées, `false` = en attente |
|
||||||
|
| `hasBl` | `true` | Avec / sans numéro de BL |
|
||||||
|
| `hasInvoice` | `false` | Avec / sans référence facture |
|
||||||
|
| `from`, `to` | `2026-10-01` | Bornes incluses sur la date de livraison (sinon date prévue) |
|
||||||
|
| `limit` | `100` | Taille de page, 100 par défaut, 500 maximum |
|
||||||
|
| `offset` | `0` | Décalage de pagination |
|
||||||
|
|
||||||
|
Paramètre mal formé : `400` avec la liste des paramètres fautifs.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"total": 1,
|
||||||
|
"limit": 100,
|
||||||
|
"offset": 0,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": 812,
|
||||||
|
"storeId": 1,
|
||||||
|
"storeName": "Magasin Nancy",
|
||||||
|
"supplierId": 4,
|
||||||
|
"supplierName": "Lactalis",
|
||||||
|
"supplierCode": "LAC01",
|
||||||
|
"automaticReconciliation": false,
|
||||||
|
"status": "delivered",
|
||||||
|
"scheduledDate": "2026-10-08",
|
||||||
|
"deliveredDate": "2026-10-08T07:42:00.000Z",
|
||||||
|
"blNumber": "BL-4567",
|
||||||
|
"blAmount": 1234.5,
|
||||||
|
"invoiceReference": null,
|
||||||
|
"invoiceAmount": null,
|
||||||
|
"invoiceAmountTTC": null,
|
||||||
|
"dueDate": null,
|
||||||
|
"reconciled": false,
|
||||||
|
"validatedAt": null,
|
||||||
|
"updatedAt": "2026-10-08T07:42:00.000Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Montants en nombres (euros), dates `YYYY-MM-DD`, horodatages ISO 8601 UTC.
|
||||||
|
|
||||||
|
### `GET /deliveries/:id` — une livraison
|
||||||
|
|
||||||
|
Même objet qu'un élément de `items`. Inconnue : `404`.
|
||||||
|
|
||||||
|
### `PATCH /deliveries/:id` — écrire la facture
|
||||||
|
|
||||||
|
Tous les champs sont facultatifs, au moins un est requis ; tout autre champ est refusé.
|
||||||
|
|
||||||
|
| Champ | Type | Effet |
|
||||||
|
|--------------------|-------------------------|------------------------------------------------------------------|
|
||||||
|
| `invoiceReference` | texte (100 car.) / null | Référence facture ; `null` ou `""` l'efface |
|
||||||
|
| `invoiceAmount` | nombre / texte / null | Montant HT. Accepte `1234.5`, `"1234,50"`, `"1 234,50"` |
|
||||||
|
| `invoiceAmountTTC` | nombre / texte / null | Montant TTC, mêmes formats |
|
||||||
|
| `dueDate` | `YYYY-MM-DD` / null | Échéance. Si absente et que la référence change, elle est reprise de NocoDB comme dans le webUI |
|
||||||
|
| `reconciled` | booléen | `true` valide le rapprochement, `false` le dévalide |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X PATCH https://logiflow.example/api/ext/v1/deliveries/812 \
|
||||||
|
-H "X-API-Key: $LOGIFLOW_API_KEY" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"invoiceReference":"FAC-2026-0042","invoiceAmount":"1234,50"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Réponse : la livraison mise à jour.
|
||||||
|
|
||||||
|
Règles :
|
||||||
|
|
||||||
|
- livraison non livrée : `409` ;
|
||||||
|
- livraison déjà rapprochée : sa facture est figée (`409`). Envoyer
|
||||||
|
`"reconciled": false` dans la même requête pour la dévalider et la modifier ;
|
||||||
|
- exception, fournisseur en **rapprochement automatique**
|
||||||
|
(`automaticReconciliation: true`) : la livraison est validée d'office dès la
|
||||||
|
saisie du BL, sa facture (référence, montants, échéance) se complète ensuite
|
||||||
|
directement, sans la dévalider ; elle reste validée ;
|
||||||
|
- corps invalide : `400` avec le détail par champ.
|
||||||
|
|
||||||
|
## Exemple : rapprocher une facture reçue
|
||||||
|
|
||||||
|
1. `GET /suppliers` → retrouver l'`id` du fournisseur via son `code`.
|
||||||
|
2. `GET /deliveries?supplierId=4&blNumber=BL-4567` → récupérer l'`id` de la livraison.
|
||||||
|
3. `PATCH /deliveries/812` avec `invoiceReference` et `invoiceAmount`
|
||||||
|
(ajouter `"reconciled": true` pour valider directement).
|
||||||
|
|
||||||
|
## Exemple : compléter la facture d'un fournisseur automatique
|
||||||
|
|
||||||
|
Les livraisons d'un fournisseur en rapprochement automatique sont déjà
|
||||||
|
validées (`reconciled: true`) ; la facture s'y ajoute sans autre champ :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X PATCH https://logiflow.example/api/ext/v1/deliveries/815 \
|
||||||
|
-H "X-API-Key: $LOGIFLOW_API_KEY" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"invoiceReference":"FAC-2026-0057","invoiceAmount":"842,10","invoiceAmountTTC":"1010,52","dueDate":"2026-11-30"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Pour les retrouver : `GET /deliveries?supplierId=<id auto>&hasInvoice=false`.
|
||||||
@@ -0,0 +1,383 @@
|
|||||||
|
# Plan de migration du serveur LogiFlow vers Rust
|
||||||
|
|
||||||
|
*Plan rédigé le 15 octobre 2026. **Migration faite** : voir [Où en est la migration](#où-en-est-la-migration) ; la suite du document est le plan d'origine, gardé tel quel.*
|
||||||
|
|
||||||
|
**Sommaire** : [Où en est la migration](#où-en-est-la-migration) · [Résumé](#résumé) · [Ce qui change, ce qui ne change pas](#ce-qui-change-ce-qui-ne-change-pas) · [Le serveur actuel](#le-serveur-actuel) · [Architecture cible](#architecture-cible) · [Stratégie : une adresse à la fois](#stratégie--une-adresse-à-la-fois) · [Feuille de route](#feuille-de-route) · [Compatibilité : les pièges](#compatibilité--les-pièges) · [Ce qu'on ne reporte pas](#ce-quon-ne-reporte-pas) · [Vérification](#vérification) · [Articulation avec le plan du webUI](#articulation-avec-le-plan-du-webui) · [Risques](#risques) · [Décisions à prendre](#décisions-à-prendre)
|
||||||
|
|
||||||
|
## Où en est la migration
|
||||||
|
|
||||||
|
**Le serveur Node a quitté le dépôt.** Le serveur Rust (`server-rs/`) est le seul serveur : il sert les 148 adresses de l'API et l'interface, applique les migrations au démarrage et lance les tâches de fond (sauvegarde automatique, purge des caches, compte `admin` par défaut). L'image Docker ne contient plus Node : un binaire statique, l'interface construite et `pg_dump`. Node ne sert plus qu'à construire l'interface (Vite) et à faire tourner le banc.
|
||||||
|
|
||||||
|
**Vérification.** Le banc rejoue 2 025 requêtes (148 adresses, 5 comptes, sans session, clé d'API ; lectures et scénarios d'écriture avec l'état des tables et les appels aux services simulés) sur le serveur Node de référence — le dernier commit qui le contient, `REFERENCE_NODE_COMMIT` — et sur Rust seul : **0 écart**, hors les écarts déclarés ci-dessous. Il tourne dans la CI (`.github/workflows/rust.yml`), avec `cargo fmt`, `clippy -D warnings` et les tests unitaires.
|
||||||
|
|
||||||
|
**Écarts voulus** (déclarés au banc) :
|
||||||
|
|
||||||
|
| Code | Changement |
|
||||||
|
|---|---|
|
||||||
|
| USERS-02 | L'empreinte du mot de passe n'est plus renvoyée après création ou modification d'un compte. |
|
||||||
|
| RAPPRO-21 | L'envoi de facture n'appelle plus une adresse de webhook quelconque : seulement celle d'un magasin ou du webhook BAP actif (400 sinon). |
|
||||||
|
| R4 | Requête d'écriture venue d'un autre site refusée (403). |
|
||||||
|
| R5 | « Aujourd'hui » à l'heure de Paris : différent de Node seulement entre minuit et 1 h (2 h l'été), heure de Paris ; le banc le signale quand il tourne dans cette fenêtre. |
|
||||||
|
|
||||||
|
Tout le reste, défauts compris, est reproduit à l'identique et signalé dans le code par l'identifiant de l'audit : c'est le point de départ des corrections, une par une, chacune déclarée au banc.
|
||||||
|
|
||||||
|
**Ce qui a différé du plan** :
|
||||||
|
|
||||||
|
- **Ordre** : sur demande, le serveur Node a été retiré dès la fin du portage, sans les deux semaines de production avec Node en secours prévues au Lot 5. La première mise en production du serveur Rust seul est donc la bascule : garder l'image précédente sous la main pour revenir en arrière (même base, mêmes sessions, aucune migration de données).
|
||||||
|
- **Requêtes SQL** vérifiées à l'exécution (`sqlx::query_as`) et non à la compilation : les vérifier à la compilation demande une base au moment du build. Le banc couvre ce que la compilation aurait vérifié.
|
||||||
|
- **Schéma** : `shared/schema.ts` reste la source ; `server-rs/src/schema.rs` en est **généré** (`npm run gen:rust-schema`), l'inverse de ce que prévoyait le Lot 5. L'interface garde ses types et ses schémas zod sans changement.
|
||||||
|
- **Migrations** : celles du démarrage de Node, reprises telles quelles dans `src/startup.rs` (idempotentes), plutôt qu'une table `_sqlx_migrations`.
|
||||||
|
- **Sessions** : table `sessions` (celle de `shared/schema.ts`), cookie `connect.sid` inchangé : les sessions ouvertes avant la bascule restent valides.
|
||||||
|
|
||||||
|
**Décisions** :
|
||||||
|
|
||||||
|
| | Statut |
|
||||||
|
|---|---|
|
||||||
|
| R1 | Prise : le serveur seul, l'interface React reste. |
|
||||||
|
| R2 | Sans objet : le portage s'est fait d'un bloc, sans gel prolongé. |
|
||||||
|
| R3 | Rien n'a été retiré : toutes les adresses, y compris l'exécution de SQL et la réinitialisation d'urgence, sont portées à l'identique. La liste [Ce qu'on ne reporte pas](#ce-quon-ne-reporte-pas) reste à trancher, adresse par adresse. |
|
||||||
|
| R4 | **Faite.** Cookie `SameSite=Lax`, et toute requête `/api` qui n'est pas une lecture est refusée (403) si le navigateur indique qu'elle vient d'un autre site : `Sec-Fetch-Site`, à défaut `Origin` comparé à l'hôte de la requête (`Host` ou `X-Forwarded-Host`). Sans en-tête (n8n, curl) elle passe ; `/api/ext/` (clé d'API) n'est pas concerné ; `ALLOWED_ORIGINS` ajoute des origines. Code : `server-rs/src/origin.rs`. |
|
||||||
|
| R5 | **Faite.** « Aujourd'hui » se calcule à Paris (`server-rs/src/clock.rs`) : produits DLC expirés ou proches, tâches à venir, météo du jour et de l'an dernier, année et mois par défaut des statistiques, numéro de ticket SAV, nom de l'export CSV, libellé des sauvegardes, et sauvegarde automatique à partir de 2 h, heure de Paris. Les instants enregistrés restent en UTC. |
|
||||||
|
| R6 | **Faite.** Mainteneur et relecteur : @R0m1k3, propriétaire du dépôt (`.github/CODEOWNERS` : `server-rs/`, banc, schéma, droits, Docker, CI). Toute modification passe par une PR avec la CI verte (mise en forme, clippy, tests, banc). À activer dans les réglages GitHub : protection de `main` avec relecture des CODEOWNERS et vérifications obligatoires. Un second relecteur connaissant Rust reste souhaitable. |
|
||||||
|
|
||||||
|
## Résumé
|
||||||
|
|
||||||
|
- **Quoi.** Réécrire le serveur (Express + TypeScript + Drizzle, environ 18 800 lignes dans `server/` et `shared/`, 148 adresses d'API) en Rust. **L'interface React reste telle quelle** : elle ne parle au serveur qu'en JSON, sous les mêmes adresses.
|
||||||
|
- **Comment.** Progressivement, jamais d'un bloc. Un serveur Rust se place devant l'actuel : il sert l'application et les adresses déjà réécrites, et transmet toutes les autres au serveur Node. Module par module, chaque adresse passe en Rust quand elle rend **exactement** les mêmes réponses (vérifié au banc de mesure), puis disparaît du code Node. À la fin, Node ne sert plus qu'à construire l'interface.
|
||||||
|
- **Ce que ça apporte.** Des types vérifiés de bout en bout, requêtes SQL comprises : les 88 erreurs TypeScript actuelles de `server/storage.ts` (comparaisons de texte et de date, champs possiblement vides) ne compileraient pas. Un serveur plus sobre en mémoire et plus rapide à démarrer, livré comme un seul binaire, sans `node_modules` en production. Et l'occasion de poser enfin des tests : le dépôt n'en a aucun.
|
||||||
|
- **Ce que ça coûte.** **4 à 6 mois pour un développeur à plein temps qui connaît Rust** (compter 1 à 2 mois de plus pour l'apprendre), pendant lesquels le serveur évolue moins vite ; deux serveurs à faire tourner pendant la transition ; une compétence Rust à garder dans l'équipe pour la maintenance.
|
||||||
|
- **Avant de commencer** (Lot 0, environ 3 semaines) : le banc de mesure versionné dans le dépôt, les sessions enregistrées dans PostgreSQL au lieu de la mémoire du serveur, le schéma réel de la base de production relevé, et les corrections de sécurité urgentes de la Phase 1a faites dans le serveur actuel, sans attendre Rust.
|
||||||
|
- Six décisions sont à prendre avant le Lot 0 ([Décisions à prendre](#décisions-à-prendre)) ; chacune a une proposition par défaut.
|
||||||
|
|
||||||
|
## Ce qui change, ce qui ne change pas
|
||||||
|
|
||||||
|
| | Change | Ne change pas |
|
||||||
|
|---|---|---|
|
||||||
|
| **Équipes en magasin** | Rien de visible, sauf les corrections décidées (fuseau horaire, protections). | Écrans, adresses, favoris, identifiants et mots de passe, session en cours au moment de la bascule. |
|
||||||
|
| **Interface** (`client/`) | En développement : `vite` lancé seul, qui renvoie `/api` vers le serveur Rust, au lieu de Vite intégré à Express (`server/vite.ts`). | React, Vite, Tailwind, React Query, le code des pages. |
|
||||||
|
| **Serveur** (`server/`) | Réécrit en Rust dans `server-rs/`, puis supprimé. | Les adresses, les réponses JSON, les codes d'erreur. |
|
||||||
|
| **Types partagés** (`shared/`) | Générés à partir du code Rust à la fin de la migration (Lot 5). | Le paquet `shared/` reste en TypeScript pour l'interface : 33 fichiers de `client/src` l'importent. |
|
||||||
|
| **Base de données** | Migrations suivies par une table (`_sqlx_migrations`) au lieu de scripts lancés à la main. | PostgreSQL, les tables, les données. Aucune migration de données. |
|
||||||
|
| **Déploiement** | Image Docker plus légère : un binaire, sans `node_modules`. | Docker Compose, le réseau nginx, le port 3000, `/api/health`, les sauvegardes `pg_dump`. |
|
||||||
|
|
||||||
|
**Pourquoi garder React.** Réécrire aussi l'interface en Rust (Leptos, Yew, Dioxus) reviendrait à refaire une trentaine de pages et leurs versions téléphone, au moment où le plan du webUI les refait déjà (Phases 2 et 3). Cela doublerait le coût sans rien apporter aux équipes : la lenteur venait du serveur et des volumes échangés (P4 du plan du webUI), pas de React. C'est la décision R1.
|
||||||
|
|
||||||
|
## Le serveur actuel
|
||||||
|
|
||||||
|
Inventaire au 15 octobre 2026, qui fixe le périmètre à reporter. Les numéros de ligne renvoient au code de cette date.
|
||||||
|
|
||||||
|
### Taille
|
||||||
|
|
||||||
|
| Fichier | Lignes | Contenu |
|
||||||
|
|---|---|---|
|
||||||
|
| `server/routes.ts` | 6 077 | 134 adresses, contrôles de droits écrits dans chaque gestionnaire |
|
||||||
|
| `server/storage.ts` | 5 914 | Accès aux données : environ 127 méthodes. Seule `DatabaseStorage` (lignes 387 à 3509) est à reporter ; `MemStorage` (stockage en mémoire de développement) disparaît. |
|
||||||
|
| `shared/schema.ts` | 1 047 | 26 tables Drizzle, schémas zod |
|
||||||
|
| `server/invoiceVerification.ts` | 922 | Vérification des factures dans NocoDB, avec cache |
|
||||||
|
| Les 27 autres fichiers | environ 4 900 | Authentification, API externe, sauvegardes, météo, courriels, chiffrement, migrations |
|
||||||
|
|
||||||
|
### Les 148 adresses, par module
|
||||||
|
|
||||||
|
| Module | Adresses | Lot |
|
||||||
|
|---|---|---|
|
||||||
|
| Santé (`/api/health`) | 1 | 1 |
|
||||||
|
| Contacts | 4 | 1 |
|
||||||
|
| Fiches fournisseurs | 4 | 1 |
|
||||||
|
| Informations (annonces) | 4 | 2 |
|
||||||
|
| Publicités (`/api/ad-campaigns`) | 7 | 2 |
|
||||||
|
| Météo | 6 | 2 |
|
||||||
|
| Statistiques (`/api/stats`) | 4 | 2 |
|
||||||
|
| Statistiques détaillées (`/api/analytics`, export CSV) | 5 | 2 |
|
||||||
|
| Tâches | 5 | 3 |
|
||||||
|
| DLC | 11 | 3 |
|
||||||
|
| Commandes clients | 6 | 3 |
|
||||||
|
| SAV | 7 | 3 |
|
||||||
|
| Commandes fournisseurs | 6 | 4 |
|
||||||
|
| Livraisons | 7 | 4 |
|
||||||
|
| Rapprochement BL / factures (commentaires, courriels fournisseurs, vérification de facture, cache) | 13 | 4 |
|
||||||
|
| Avoirs | 8 | 4 |
|
||||||
|
| Échéancier (dont export CSV) | 2 | 4 |
|
||||||
|
| Configuration NocoDB | 5 | 4 |
|
||||||
|
| Bon à payer (BAP) et webhook | 4 | 4 |
|
||||||
|
| API externe (`/api/ext/v1`) et gestion de ses clés | 8 | 4 |
|
||||||
|
| Relais ffnancy | 2 | 4 |
|
||||||
|
| Authentification | 6 | 5 |
|
||||||
|
| Utilisateurs | 8 | 5 |
|
||||||
|
| Magasins (`/api/groups`) | 5 | 5 |
|
||||||
|
| Sauvegardes | 4 | 5 |
|
||||||
|
| Paramètres (`/api/utilities`) | 2 | 5 |
|
||||||
|
| Administration et diagnostic (SQL, schéma, migration d'urgence) | 4 | 5 (décision R3) |
|
||||||
|
| **Total** | **148** | |
|
||||||
|
|
||||||
|
Le Calendrier n'a pas d'adresse à lui : il lit les commandes, les livraisons et les publicités. Il est entièrement servi par Rust à la fin du Lot 4.
|
||||||
|
|
||||||
|
### Ce que l'inventaire a révélé
|
||||||
|
|
||||||
|
Ces constats changent la façon de migrer ; certains sont déjà dans l'audit du webUI.
|
||||||
|
|
||||||
|
- **La production n'utilise pas `localAuth.production.ts`.** `server/routes.ts:4` importe `./localAuth` et c'est lui qui s'exécute : sessions dans la mémoire du processus (perdues à chaque redémarrage, AUTH-06), cookie `connect.sid` valable 24 heures, sans `sameSite`. `localAuth.production.ts`, `db.production.ts` et `monitoring.ts` ne sont jamais exécutés.
|
||||||
|
- **Aucune protection CSRF, aucune limite de débit, aucun en-tête de sécurité en production** : `server/security.ts` n'est branché que dans `server/index.ts`, qui n'est pas le point d'entrée de l'image Docker (`server/index.production.ts`). L'interface n'envoie d'ailleurs aucun jeton CSRF.
|
||||||
|
- **Mots de passe** : `scrypt` de Node (N = 16384, r = 8, p = 1, 64 octets), stockés en `empreinte_hex.sel_hex`, le sel hexadécimal étant utilisé tel quel comme texte (`server/localAuth.ts:20-57`). Le paquet `scrypt` de Rust les vérifie à l'identique : **aucun mot de passe à réinitialiser**.
|
||||||
|
- **Secrets chiffrés** (mot de passe SMTP des magasins, jeton NocoDB) : AES-256-GCM, clé = SHA-256 de `ENCRYPTION_KEY` (à défaut `SESSION_SECRET`), format `enc:v1:iv:tag:données` en base64 (`server/crypto.ts`). Reproductible à l'identique avec le paquet `aes-gcm`.
|
||||||
|
- **Droits** : une seule adresse utilise un intergiciel de droits (`requireAdmin`, `server/routes.ts:130`). Les autres contrôlent le rôle à la main (environ 131 comparaisons `user.role` et 14 appels `hasPermission`), et DLC, Tâches et Commandes clients ne contrôlent que le magasin.
|
||||||
|
- **Base** : aucune migration n'est suivie en production. `init.sql` ne crée que 25 tables et ignore des colonnes ajoutées depuis à la main (`contacts`, `suppliers.email`, `customer_orders.notified_at`…). Seul le schéma réel de la production fait foi.
|
||||||
|
- **Aucune transaction** dans tout le serveur, et un numéro de SAV calculé en « nombre de tickets + 1 » (`server/storage.ts:2824`), qui peut produire des doublons.
|
||||||
|
- **Dépendances inutilisées** : `xlsx`, `iconv-lite`, `multer`, `busboy`, `node-cron`, `csrf-csrf`, `openid-client`, `memoizee` ne sont importés nulle part. Pas de génération Excel ni PDF côté serveur ; les exports sont en CSV.
|
||||||
|
- **Aucun test** dans le dépôt. La seule intégration continue construit l'image Docker.
|
||||||
|
|
||||||
|
## Architecture cible
|
||||||
|
|
||||||
|
### Choix techniques
|
||||||
|
|
||||||
|
| Besoin | Aujourd'hui (Node) | En Rust |
|
||||||
|
|---|---|---|
|
||||||
|
| Serveur HTTP | Express | `axum` sur `tokio` |
|
||||||
|
| Compression, fichiers statiques, limite de taille, journal des requêtes | `compression`, `express.static`, `express.json({ limit: '10mb' })` | `tower-http` (`CompressionLayer`, `ServeDir`, `RequestBodyLimitLayer`, `TraceLayer`) |
|
||||||
|
| Base de données | Drizzle + `pg` | `sqlx` (PostgreSQL) : requêtes SQL écrites en clair, **vérifiées à la compilation** contre le schéma ; cache `.sqlx/` versionné pour compiler sans base |
|
||||||
|
| Migrations | scripts au démarrage + `psql` dans le `CMD` du Dockerfile | `sqlx::migrate!`, fichiers SQL numérotés, table de suivi |
|
||||||
|
| Validation des entrées | zod | `serde` (forme) + `validator` ou contrôles écrits à la main (règles) |
|
||||||
|
| Sessions, connexion | `express-session` + `passport-local` | intergiciel maison, d'environ 200 lignes : lecture du cookie signé, table `session` (voir [Sessions](#sessions-partagées-entre-node-et-rust)) |
|
||||||
|
| Mots de passe | `crypto.scrypt` | `scrypt` (mêmes paramètres) |
|
||||||
|
| Chiffrement des secrets | `crypto` AES-256-GCM | `aes-gcm`, `sha2`, `base64` |
|
||||||
|
| Clés de l'API externe | SHA-256 + `timingSafeEqual` | `sha2` + `subtle` |
|
||||||
|
| Appels sortants (NocoDB, Visual Crossing, Nominatim, ffnancy, webhooks n8n) | `node-fetch` | `reqwest` (TLS `rustls`), délai maximal sur chaque appel |
|
||||||
|
| Courriels fournisseurs | `nodemailer` | `lettre` (logo du magasin en pièce jointe intégrée `cid:logo`) |
|
||||||
|
| Sauvegardes | `exec('pg_dump … > fichier')` dans un shell | `tokio::process::Command` avec arguments séparés, sans shell |
|
||||||
|
| Tâches de fond | `setInterval` | tâches `tokio` (`tokio::time::interval`) |
|
||||||
|
| Dates et fuseau | `Date` de JavaScript | `chrono` + `chrono-tz` (`Europe/Paris`, décision R5) |
|
||||||
|
| Montants | texte renvoyé par `pg` | `rust_decimal`, sérialisé en texte (voir [Compatibilité](#compatibilité--les-pièges)) |
|
||||||
|
| Journaux | environ 150 `console.log` dans `routes.ts` | `tracing`, à niveaux, sans corps de requête ni données clients |
|
||||||
|
| Types pour l'interface | `shared/schema.ts` | `ts-rs` : les types TypeScript sont générés depuis les structures Rust (Lot 5) |
|
||||||
|
|
||||||
|
Les versions sont figées dans `Cargo.lock`, versionné. Une seule édition de Rust et une version minimale (`rust-version`) inscrites dans `Cargo.toml`.
|
||||||
|
|
||||||
|
### Organisation du code
|
||||||
|
|
||||||
|
Un seul paquet (`crate`) pour commencer, découpé en modules qui reprennent ceux de l'application. On ne le découpe en plusieurs paquets que si la compilation devient trop lente.
|
||||||
|
|
||||||
|
```
|
||||||
|
server-rs/
|
||||||
|
Cargo.toml
|
||||||
|
migrations/ fichiers SQL numérotés (sqlx), le premier = schéma de production relevé
|
||||||
|
.sqlx/ requêtes vérifiées, pour compiler sans base (SQLX_OFFLINE=true)
|
||||||
|
src/
|
||||||
|
main.rs configuration, démarrage, migrations, tâches de fond
|
||||||
|
router.rs table des adresses servies par Rust ; tout le reste part vers Node
|
||||||
|
proxy.rs relais vers le serveur Node (supprimé au Lot 5)
|
||||||
|
auth/ session, cookie signé, utilisateur courant, connexion
|
||||||
|
access.rs droits (rôle × module) et cloisonnement par magasin
|
||||||
|
json.rs formats partagés : dates, montants, erreurs { message }
|
||||||
|
crypto.rs secrets chiffrés (compatible enc:v1)
|
||||||
|
modules/ un fichier par module : contacts.rs, dlc.rs, deliveries.rs…
|
||||||
|
chacun avec ses gestionnaires HTTP et ses requêtes SQL
|
||||||
|
integrations/ nocodb.rs, mail.rs, weather.rs, webhooks.rs, ffnancy.rs, backup.rs
|
||||||
|
jobs.rs sauvegarde quotidienne, purge du cache des factures
|
||||||
|
tests/ tests d'intégration contre une base PostgreSQL jetable
|
||||||
|
```
|
||||||
|
|
||||||
|
**Une seule table de droits.** La matrice de `shared/permissions.ts` passe dans un fichier de données, `shared/permissions.json`, lu par le serveur Rust (`include_str!` + `serde`) et par l'interface (import JSON). C'est la « table de droits unique » de la Phase 2 du plan du webUI, sans recopie entre les deux langages. Chaque gestionnaire Rust déclare son module et son action ; le contrôle est fait par un extracteur `axum`, plus dans le corps de chaque fonction.
|
||||||
|
|
||||||
|
### Sessions partagées entre Node et Rust
|
||||||
|
|
||||||
|
Pendant la transition, un utilisateur connecté par Node doit être reconnu par Rust, et inversement. Les sessions en mémoire du processus Node ne le permettent pas : **il faut d'abord les enregistrer dans PostgreSQL** (Lot 0). C'est aussi la correction d'AUTH-06 (sessions perdues à chaque redéploiement), déjà prévue par le plan du webUI.
|
||||||
|
|
||||||
|
- **Côté Node (Lot 0)** : `connect-pg-simple` (déjà dans les dépendances), table `session (sid, sess, expire)`. Les utilisateurs se reconnectent une fois, à cette livraison-là.
|
||||||
|
- **Côté Rust (Lot 1)** : lire le cookie `connect.sid` (`s:` + identifiant + `.` + signature HMAC-SHA256 avec `SESSION_SECRET`, en base64 sans `=`), vérifier la signature en temps constant, lire `sess->'passport'->>'user'` dans la table `session` si `expire` n'est pas dépassé, puis charger l'utilisateur et ses magasins. Une seule lecture de l'utilisateur par requête (règle « utilisateur courant » de P4 § 5.2).
|
||||||
|
- **Connexion et déconnexion** restent dans Node jusqu'au Lot 5. Rust les reprend alors en écrivant des sessions **au même format** : aucune reconnexion à la bascule, et un retour à Node reste possible.
|
||||||
|
|
||||||
|
## Stratégie : une adresse à la fois
|
||||||
|
|
||||||
|
```
|
||||||
|
navigateur (PC, téléphone)
|
||||||
|
│
|
||||||
|
nginx (réseau nginx_default)
|
||||||
|
│
|
||||||
|
┌───────────────────▼────────────────────┐
|
||||||
|
│ logiflow : serveur Rust, port 3000 │
|
||||||
|
│ /assets, index.html, adresses portées │
|
||||||
|
└───────────────────┬────────────────────┘
|
||||||
|
│ toute autre adresse /api
|
||||||
|
┌───────────────────▼────────────────────┐
|
||||||
|
│ logiflow-node : serveur actuel │
|
||||||
|
│ port 3001, interne, non exposé │
|
||||||
|
└───────────────────┬────────────────────┘
|
||||||
|
│
|
||||||
|
PostgreSQL (logiflow-db), table session commune
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Le serveur Rust est devant dès le premier jour**, avec zéro adresse portée : il sert l'interface (`/assets` avec cache d'un an, `index.html` pour toutes les autres adresses de l'application) et relaie tout `/api` à Node. Cette première livraison ne change rien pour les équipes et valide la tuyauterie en production.
|
||||||
|
- **Le relais** transmet la méthode, l'adresse, les en-têtes (cookie, `X-API-Key`, `Authorization`, type de contenu) et le corps en flux, sans le lire (l'envoi de facture en `multipart` passe tel quel). Il rend la réponse telle quelle, `Set-Cookie` et redirections 302 comprises (la déconnexion redirige vers `/auth`), sans les suivre. Délai maximal de 120 secondes : certains appels attendent NocoDB ou un webhook jusqu'à 60 secondes.
|
||||||
|
- **La table des adresses est dans le code** (`router.rs`) : une adresse est servie par Rust si elle y figure, par Node sinon. Faire revenir une adresse à Node, c'est retirer sa ligne et redéployer.
|
||||||
|
- **Une adresse vit à un seul endroit.** Quand elle passe en Rust, son code Node n'est plus appelé ; on le supprime après deux semaines en production sans régression, dans une PR dédiée.
|
||||||
|
- **Les tâches de fond ne tournent jamais dans les deux serveurs** : sauvegarde quotidienne, purge du cache des factures, configuration météo et création des index restent dans Node jusqu'au Lot 5, puis passent toutes ensemble en Rust.
|
||||||
|
- **Le schéma ne bouge pas pendant qu'un module migre.** Un changement de schéma passe par un fichier de `server-rs/migrations/`, appliqué par Rust au démarrage. Les migrations de démarrage de Node (`server/migrations.production.ts`, toutes en `IF NOT EXISTS`) continuent jusqu'au Lot 5, puis deviennent des fichiers SQL.
|
||||||
|
|
||||||
|
## Feuille de route
|
||||||
|
|
||||||
|
Effort pour un développeur à plein temps qui connaît Rust ; échelle **S** / **M** / **L** du plan du webUI. Chaque lot se termine par une livraison en production et deux semaines de suivi, qui recouvrent le début du lot suivant.
|
||||||
|
|
||||||
|
| Lot | Contenu | Adresses | Effort | Prérequis |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| [0. Préparation](#lot-0--préparation) | Banc, sessions en base, schéma relevé, squelette Rust devant Node | 0 | 3 semaines | Décisions R1 à R6 ; Phase 1a du webUI |
|
||||||
|
| [1. Socle](#lot-1--socle) | Session, droits, magasin, formats JSON, premiers modules | 9 | 2 à 3 semaines | Lot 0 en production |
|
||||||
|
| [2. Informations et planning](#lot-2--informations-et-planning) | Annonces, Publicités, Météo, Statistiques | 26 | 3 semaines | Lot 1 |
|
||||||
|
| [3. Au quotidien](#lot-3--au-quotidien) | Tâches, DLC, Commandes clients, SAV | 29 | 3 à 4 semaines | Lot 1 |
|
||||||
|
| [4. Fournisseurs](#lot-4--fournisseurs) | Commandes, Livraisons, Rapprochement, Avoirs, Échéancier, NocoDB, BAP, API externe | 55 | 6 à 8 semaines | Lot 1 |
|
||||||
|
| [5. Administration et bascule](#lot-5--administration-et-bascule) | Connexion, Utilisateurs, Magasins, Sauvegardes, Paramètres, tâches de fond ; arrêt de Node | 29 | 3 à 4 semaines | Lots 2 à 4 |
|
||||||
|
| **Total** | | **148** | **20 à 25 semaines** | |
|
||||||
|
|
||||||
|
Les lots 2, 3 et 4 sont indépendants entre eux : un second développeur peut en prendre un en parallèle. L'ordre proposé va du plus simple au plus délicat : les premiers lots rodent les outils sur des modules à faible risque, et le Lot 4, qui porte l'essentiel de la logique métier (factures, NocoDB, rapprochement), arrive quand la méthode est éprouvée.
|
||||||
|
|
||||||
|
### Lot 0 — Préparation
|
||||||
|
|
||||||
|
**Objectif.** Pouvoir prouver, adresse par adresse, que Rust répond comme Node ; et poser le serveur Rust en production sans rien changer.
|
||||||
|
|
||||||
|
1. **Verser le banc de mesure dans le dépôt** (`scripts/bench/`, c'est le lot 2, n° 0 du plan du webUI), et l'étendre :
|
||||||
|
- les 148 adresses, pour 5 comptes : administrateur, directeur multi-magasins, manager, employé, compte sans magasin ;
|
||||||
|
- des scénarios d'écriture (créer, modifier, valider, supprimer) rejoués sur une copie neuve de la base, avec comparaison de l'état des tables touchées après coup ;
|
||||||
|
- la comparaison porte sur le code HTTP, le corps JSON (valeurs, ordre des listes, champs `null`), et les en-têtes qui comptent (`Content-Type`, `Cache-Control`, `Set-Cookie`, `Location`) ;
|
||||||
|
- une horloge figée pour les réponses qui dépendent de « aujourd'hui ».
|
||||||
|
2. **Sessions dans PostgreSQL** côté Node (`connect-pg-simple`, table `session`), cookie `sameSite: 'lax'`. Corrige AUTH-06.
|
||||||
|
3. **Relever le schéma réel de la production** (`pg_dump --schema-only` sur une copie) ; il devient `server-rs/migrations/0001_schema_initial.sql`, marqué comme déjà appliqué sur les bases existantes. Noter les écarts avec `init.sql`.
|
||||||
|
4. **Squelette Rust** : configuration (mêmes variables d'environnement : `DATABASE_URL`, `SESSION_SECRET`, `ENCRYPTION_KEY`, `EXTERNAL_API_KEYS`, `VISUAL_CROSSING_API_KEY`, `BACKUP_DIR`, `PORT`), `/api/health`, fichiers statiques, renvoi de `index.html`, relais vers Node, journaux `tracing`, arrêt propre.
|
||||||
|
5. **Docker** : un service `logiflow` (Rust, port 3000) et un service `logiflow-node` (port 3001, non exposé) dans `docker-compose.yml` ; Dockerfile en étapes : interface construite avec Node, binaire Rust compilé, image finale avec le binaire, `dist/public` et `postgresql-client` (pour `pg_dump`).
|
||||||
|
6. **Intégration continue** : `cargo fmt --check`, `cargo clippy -- -D warnings`, `cargo test` avec un service PostgreSQL, compilation en `SQLX_OFFLINE=true`, en plus de la construction de l'image existante.
|
||||||
|
|
||||||
|
**Critères de réussite.**
|
||||||
|
|
||||||
|
- Le banc passe sur l'ensemble des 148 adresses, avec Rust devant Node : 0 différence de statut, de corps ou d'en-tête par rapport à Node seul.
|
||||||
|
- En production : connexion, déconnexion (302 vers `/auth`), envoi d'une facture en `multipart`, envoi d'un BAP (PDF de plusieurs Mo), appel de l'API externe avec sa clé, téléchargement d'une sauvegarde : tout fonctionne à travers le relais.
|
||||||
|
- Un redéploiement ne déconnecte plus personne.
|
||||||
|
|
||||||
|
### Lot 1 — Socle
|
||||||
|
|
||||||
|
**Objectif.** Tout ce dont chaque module aura besoin, éprouvé sur trois modules simples.
|
||||||
|
|
||||||
|
- **Utilisateur courant** : extracteur `axum` qui lit la session et charge l'utilisateur avec ses magasins, une fois par requête ; 401 `{ "message": … }` sinon.
|
||||||
|
- **Droits et magasins** : `shared/permissions.json` (voir [Organisation du code](#organisation-du-code)) ; extracteurs « module + action » et « magasin autorisé ». Les règles appliquées sont celles du serveur Node à ce moment-là, y compris les corrections de la Phase 1a.
|
||||||
|
- **Formats JSON** communs ([Compatibilité](#compatibilité--les-pièges)) : dates, horodatages, montants, noms de champs, `smtpPasswordSet` à la place du mot de passe SMTP, `Cache-Control: private, no-cache` sur `/api`.
|
||||||
|
- **Erreurs** : `{ "message": "…" }` en français, avec les mêmes codes HTTP que Node.
|
||||||
|
- **Chiffrement** compatible `enc:v1`, testé sur des valeurs chiffrées par le code Node.
|
||||||
|
- **Modules** : Santé (1), Contacts (4), Fiches fournisseurs (4).
|
||||||
|
|
||||||
|
**Critères de réussite.** 0 différence au banc sur les 9 adresses pour les 5 comptes ; un secret chiffré par Node est relu par Rust et inversement ; tests unitaires des droits pour chaque case du tableau rôle × module.
|
||||||
|
|
||||||
|
### Lot 2 — Informations et planning
|
||||||
|
|
||||||
|
Annonces (4), Publicités (7), Météo (6, avec Visual Crossing et Nominatim), Statistiques (4) et Statistiques détaillées (5, dont l'export CSV). Ce sont surtout des lectures et des agrégats : c'est le lot qui rode `sqlx`, les requêtes construites selon les filtres (aujourd'hui assemblées en texte dans `server/storage.ts:3337-3386` et `:3450-3487`, à remplacer par des requêtes paramétrées) et les exports CSV.
|
||||||
|
|
||||||
|
**Critères de réussite.** 0 différence au banc sur les 26 adresses ; mêmes octets pour les exports CSV ; aucun appel externe sans délai maximal.
|
||||||
|
|
||||||
|
### Lot 3 — Au quotidien
|
||||||
|
|
||||||
|
Tâches (5), DLC (11), Commandes clients (6), SAV (7). Les modules les plus utilisés en magasin, mais à la logique simple. Le numéro de SAV est attribué par la base (séquence ou `INSERT … RETURNING` dans une transaction) au lieu du comptage actuel, sans changer son format `SAV-AAAA-0001`.
|
||||||
|
|
||||||
|
**Critères de réussite.** 0 différence au banc sur les 29 adresses et les scénarios d'écriture (création, modification, validation, suppression, marquage d'une DLC traitée, client prévenu).
|
||||||
|
|
||||||
|
### Lot 4 — Fournisseurs
|
||||||
|
|
||||||
|
Commandes fournisseurs (6), Livraisons (7), Rapprochement BL / factures (13), Avoirs (8), Échéancier (2), Configuration NocoDB (5), BAP et webhook (4), API externe et ses clés (8), relais ffnancy (2). Le lot le plus lourd :
|
||||||
|
|
||||||
|
- `server/invoiceVerification.ts` (922 lignes) : recherche dans NocoDB par référence de facture puis par numéro de BL, rapprochement du nom de fournisseur, cache avec ses trois durées (6 heures, 12 heures, quasi permanent une fois rapproché). Reporter aussi le second client NocoDB (`server/nocodbService.ts`) ou, si `/api/verify-invoices` n'a pas d'appelant, le supprimer (décision R3) ;
|
||||||
|
- normalisation des échéances NocoDB (`server/dateUtils.ts`, jour avant mois par défaut) ;
|
||||||
|
- courriel de demande de documents au fournisseur (`shared/supplierMail.ts`, logo intégré), avec son journal ;
|
||||||
|
- API externe : clés `lf_…` (seules l'empreinte SHA-256 et un préfixe sont stockés), montants saisis à la française (« 1 234,56 »), règles de gel d'un rapprochement ; documentation `docs/API-RAPPROCHEMENT.md` inchangée ;
|
||||||
|
- les écritures qui touchent plusieurs tables (validation d'une livraison, rapprochement) passent dans une transaction.
|
||||||
|
|
||||||
|
**Critères de réussite.** 0 différence au banc sur les 55 adresses ; NocoDB et les webhooks remplacés au banc par un faux service qui enregistre les appels reçus, identiques entre Node et Rust ; l'outil externe qui appelle `/api/ext/v1` fonctionne sans changement de sa part.
|
||||||
|
|
||||||
|
### Lot 5 — Administration et bascule
|
||||||
|
|
||||||
|
- **Connexion et déconnexion** (6 adresses, dont la réinitialisation d'urgence : décision R3), **Utilisateurs** (8), **Magasins** (5), **Sauvegardes** (4), **Paramètres** (2), **Administration et diagnostic** (4, décision R3).
|
||||||
|
- **Tâches de fond** en Rust : sauvegarde quotidienne après 2 h (heure de Paris), purge du cache des factures toutes les 6 heures, configuration météo au démarrage ; création des index et migrations de démarrage converties en fichiers de `server-rs/migrations/`.
|
||||||
|
- **Types partagés** : `ts-rs` génère `shared/api/*.ts` depuis les structures Rust ; les imports de types de `@shared/schema` dans `client/src` sont remplacés ; les trois schémas zod de formulaire utilisés par l'interface (`insertTaskSchema`, `insertCustomerOrderFrontendSchema`, `insertAnnouncementSchema`) restent écrits à la main dans `shared/forms.ts`.
|
||||||
|
- **Arrêt de Node** : suppression de `server/`, du relais, du service `logiflow-node`, de Drizzle et des dépendances serveur de `package.json` ; l'image finale ne contient plus Node.
|
||||||
|
|
||||||
|
**Critères de réussite.** 0 différence au banc sur les 148 adresses, Rust seul ; un utilisateur connecté avant la bascule reste connecté après ; une sauvegarde automatique est créée la nuit suivante et téléchargeable ; `grep -r "server/" Dockerfile package.json` ne renvoie plus rien.
|
||||||
|
|
||||||
|
## Compatibilité : les pièges
|
||||||
|
|
||||||
|
L'interface lit les réponses JSON telles que Node les produit aujourd'hui. Chaque écart ci-dessous casserait un écran sans erreur visible ; le banc les détecte, mais autant les traiter dès le Lot 1.
|
||||||
|
|
||||||
|
| Sujet | Ce que Node renvoie | Ce qu'il faut en Rust |
|
||||||
|
|---|---|---|
|
||||||
|
| Montants (`decimal(10,2)`, 7 colonnes) | du texte : `"12.50"` | `rust_decimal` sérialisé en texte, échelle conservée ; jamais un nombre JSON |
|
||||||
|
| Dates (`date`, 10 colonnes) | `"2026-10-15"` | `chrono::NaiveDate` (format par défaut identique) |
|
||||||
|
| Horodatages (`timestamp` sans fuseau, 62 colonnes) | `"2026-10-15T08:30:00.000Z"` : lus comme UTC, millisecondes et `Z` | sérialiseur dédié `%Y-%m-%dT%H:%M:%S%.3fZ` ; le format par défaut de `chrono` n'a ni `Z` ni millisecondes fixes |
|
||||||
|
| Noms de champs | `camelCase` (Drizzle) ; certaines requêtes brutes rendent du `snake_case` | `#[serde(rename_all = "camelCase")]` par défaut, exceptions relevées au banc |
|
||||||
|
| Champs vides | présents avec `null` | ne jamais omettre un champ `None` |
|
||||||
|
| Objets imbriqués | `group`, `supplier`, `creator` dans la plupart des listes ; `dlcDate` en plus de `expiryDate` pour les DLC | mêmes structures, construites en une requête avec jointures |
|
||||||
|
| Identifiants | entiers (`serial`), sauf `users.id` (texte, ex. `admin_local`) et `database_backups.id` (nanoid) | mêmes types |
|
||||||
|
| Mot de passe SMTP | remplacé par `smtpPasswordSet: true/false` dans toute réponse | jamais sélectionné dans les requêtes ; `smtpPasswordSet` calculé en SQL |
|
||||||
|
| Erreurs | `{ "message": "…" }`, 401 si non connecté | identique |
|
||||||
|
| Déconnexion | redirection 302 vers `/auth` | identique |
|
||||||
|
| « Aujourd'hui » | `new Date().toISOString().split('T')[0]` : date UTC, fausse entre minuit et 2 h l'été | heure de Paris (décision R5), écart déclaré au banc |
|
||||||
|
| Corps des requêtes | jusqu'à 10 Mo (logos en data URI, PDF en base64) | même limite |
|
||||||
|
|
||||||
|
## Ce qu'on ne reporte pas
|
||||||
|
|
||||||
|
Une migration est l'occasion de ne pas recopier ce qui est dangereux ou mort. Chaque ligne est à confirmer (décision R3) ; les adresses sans appelant connu sont d'abord cherchées dans les journaux d'accès de production et les workflows n8n.
|
||||||
|
|
||||||
|
| Élément | Où | Proposition |
|
||||||
|
|---|---|---|
|
||||||
|
| Exécution de SQL arbitraire | `POST /api/admin/execute-sql` (`server/routes.ts:5797`), SQL-01 | Ne pas reporter : la base s'administre avec `psql`. |
|
||||||
|
| Réinitialisation d'urgence de l'administrateur | `POST /api/emergency-admin-reset` (`server/index.production.ts:87`) | Remplacer par une commande du binaire (`logiflow reset-admin`), lancée dans le conteneur. |
|
||||||
|
| Compte `admin` / `admin` créé au démarrage, mot de passe `admin.salt` accepté | `server/localAuth.ts:30`, `:59`, AUTH-04 | Ne pas reporter : premier administrateur créé par la commande ci-dessus. |
|
||||||
|
| Adresse de webhook fournie par le navigateur | `POST /api/reconciliation/send-invoice` (`server/routes.ts:823`), RAPPRO-21 | Adresse lue en base uniquement (configuration du magasin). |
|
||||||
|
| Nom de fichier de sauvegarde non contrôlé | `/api/backups/:filename` (`server/routes.ts:4916`, `:4938`), BACKUP-09 | Identifiant de sauvegarde lu en base, jamais un chemin. |
|
||||||
|
| Géolocalisation météo modifiable par tout utilisateur connecté | `POST /api/weather/geolocation` (`server/routes.ts:5740`) | Réservée à l'administrateur. |
|
||||||
|
| Routes masquées par une route identique déclarée avant | `POST /api/users/:id/groups`, `DELETE /api/users/:id/groups/:groupId` (`server/routes.ts:4179`, `:4199`) | Ne pas reporter (jamais atteintes). |
|
||||||
|
| Adresses sans appelant dans `client/src` | `bap/send-webhook`, `sync-order-delivery-status`, `cache/diagnosis`, `cache/update-reconciled`, `verify-invoice`, `verify-invoices`, `avoirs/:id/webhook-status`, `nocodb-config/active`, `ad-campaigns/debug`, `admin/emergency-migration`, diagnostic du schéma | Reporter seulement celles qui ont un appelant en production. |
|
||||||
|
| Stockage en mémoire de développement | `MemStorage`, `server/storage.ts:3510-5909` | Ne pas reporter : le développement utilise une base PostgreSQL locale (Docker). |
|
||||||
|
| Pilote Neon par WebSocket | `server/db.ts:55` | Ne pas reporter : PostgreSQL direct partout. |
|
||||||
|
|
||||||
|
**Protection des requêtes d'écriture** (décision R4). La production n'en a aucune aujourd'hui. Proposition : cookie `SameSite=Lax` et contrôle de l'en-tête `Origin` sur toute requête qui n'est pas une lecture, sauf `/api/ext/` (clé d'API). Les navigateurs envoient cet en-tête d'eux-mêmes : rien à changer dans l'interface.
|
||||||
|
|
||||||
|
## Vérification
|
||||||
|
|
||||||
|
- **Le banc fait foi.** Une adresse passe en Rust quand le banc ne montre aucune différence avec Node pour les 5 comptes, en lecture et en écriture. Une différence voulue (fuseau horaire, faille fermée) est inscrite dans la liste des écarts attendus du banc, avec sa raison.
|
||||||
|
- **Bug pour bug, sauf décision.** On reporte le comportement de Node tel qu'il est au moment du portage. Un bug découvert en route est corrigé d'abord dans Node (et vérifié au banc), ou inscrit comme écart attendu ; jamais corrigé en silence dans Rust.
|
||||||
|
- **Tests Rust** :
|
||||||
|
- unitaires sur les règles pures : droits (chaque case du tableau rôle × module), cloisonnement par magasin, recherche de facture, normalisation des dates, montants à la française, exports CSV ;
|
||||||
|
- de compatibilité, sur des valeurs produites par le code Node : empreintes de mots de passe, secrets `enc:v1`, cookies signés, clés d'API ;
|
||||||
|
- d'intégration, contre une base PostgreSQL jetable, par module.
|
||||||
|
- **Requêtes SQL vérifiées à la compilation** par `sqlx` : une colonne renommée ou un type faux ne compile plus.
|
||||||
|
- **Mesures de performance** au banc, Node puis Rust, à chaque lot : temps de réponse (médiane et 95ᵉ centile) par adresse, mémoire du serveur, temps de démarrage, taille de l'image. Objectif : aucune adresse plus lente qu'avec Node. Les gains sont à mesurer, pas à supposer.
|
||||||
|
- **Avec chaque rôle, sur PC et téléphone**, comme pour toute livraison (règles communes du plan du webUI).
|
||||||
|
|
||||||
|
## Articulation avec le plan du webUI
|
||||||
|
|
||||||
|
La migration ne remplace pas le plan d'optimisation du webUI (`docs/PLAN-OPTIMISATION-WEBUI.md`) ; elle change l'endroit où se fait sa partie serveur.
|
||||||
|
|
||||||
|
| Phase du plan du webUI | Avec la migration |
|
||||||
|
|---|---|
|
||||||
|
| **1a — Corrections critiques** | **À faire dans Node, avant le Lot 0**, sans attendre : ce sont des fuites de données et des failles en production aujourd'hui. Rust reprend ensuite le comportement corrigé. |
|
||||||
|
| **1b — Corrections rapides de l'interface** | Inchangée : elle ne touche que `client/`. |
|
||||||
|
| **2 — Socle** | La partie interface (menu, design system, composants) avance en parallèle. Les règles serveur T2 à T7 et la table de droits unique se font **dans Rust**, module par module, au lieu d'être écrites deux fois. |
|
||||||
|
| **3 — Refonte des pages** | En parallèle. Quand une page a besoin d'une nouvelle adresse (synthèse de l'accueil DASH-05, listes paginées), elle s'écrit en Rust si son module est déjà porté, et son module passe en tête de file sinon. |
|
||||||
|
| **4 — Performance, lots 2 et 3** | Le banc (lot 2, n° 0) devient le Lot 0 de la migration. Les chantiers côté serveur (requêtes ciblées, échéancier en SQL, journal à niveaux) se font en Rust, lors du portage du module concerné. |
|
||||||
|
| **5 — Dette** | La partie serveur (point d'entrée unique, suppression de `localAuth.production.ts`, `console.log`, SQL paramétré, identifiants générés par le serveur) est absorbée par la migration. Restent la fusion PC / téléphone et la dette de l'interface. |
|
||||||
|
|
||||||
|
## Risques
|
||||||
|
|
||||||
|
| Risque | Conséquence | Parade |
|
||||||
|
|---|---|---|
|
||||||
|
| Effort sous-estimé, surtout au Lot 4 | Deux serveurs plus longtemps que prévu | Lots indépendants, chacun livré seul : la migration peut s'arrêter après n'importe quel lot sans rien casser, Node servant le reste. |
|
||||||
|
| Écart de format JSON non vu | Un écran affiche une valeur fausse ou vide, sans erreur | Banc sur 5 comptes, en écriture comme en lecture ; types de l'interface générés depuis Rust au Lot 5. |
|
||||||
|
| Sessions incompatibles | Utilisateurs déconnectés, ou pire, connectés sous un autre compte | Lecture du cookie signé testée sur des cookies produits par Node ; une session illisible vaut « non connecté », jamais un autre utilisateur. |
|
||||||
|
| Tâche de fond lancée deux fois | Deux sauvegardes, purges concurrentes | Toutes les tâches restent dans Node jusqu'au Lot 5, puis passent ensemble. |
|
||||||
|
| Changement de schéma pendant la transition | Node et Rust en désaccord sur une table | Gel du schéma du module en cours ; tout changement passe par `server-rs/migrations/`. |
|
||||||
|
| Compétence Rust dans l'équipe | Personne pour maintenir le serveur | Décision R6 avant de commencer ; code simple, sans abstraction gratuite, commentaires en français comme le reste du dépôt. |
|
||||||
|
| Nouvelles demandes métier pendant la migration | Tentation d'écrire les fonctions deux fois | Gel des nouvelles fonctions serveur pour le module en cours de portage seulement (décision R2). |
|
||||||
|
| Mémoire : deux serveurs pendant la transition | Plus de mémoire consommée sur l'hôte | Le serveur Rust ajoute peu ; à mesurer au Lot 0. |
|
||||||
|
|
||||||
|
## Décisions à prendre
|
||||||
|
|
||||||
|
| N° | Question | Proposition par défaut |
|
||||||
|
|---|---|---|
|
||||||
|
| R1 | **Périmètre** : le serveur seul, ou aussi l'interface en Rust ? | **Le serveur seul.** L'interface React reste, et sa refonte suit le plan du webUI. |
|
||||||
|
| R2 | **Gel des fonctions** : peut-on suspendre les évolutions serveur d'un module pendant son portage (1 à 4 semaines) ? | Oui, module par module ; les urgences passent dans Node puis sont reportées au banc. |
|
||||||
|
| R3 | **Ce qu'on ne reporte pas** : la liste de [Ce qu'on ne reporte pas](#ce-quon-ne-reporte-pas). | Tout ce qui y est proposé, après lecture des journaux d'accès de production pour les adresses sans appelant connu. |
|
||||||
|
| R4 | **Protection des requêtes d'écriture** : l'ajouter à la migration ? | Oui : `SameSite=Lax` et contrôle de `Origin`, sans changement de l'interface. |
|
||||||
|
| R5 | **Fuseau horaire** : « aujourd'hui » et la sauvegarde de 2 h à l'heure de Paris ? | Oui (c'est T5 du plan du webUI) ; écart déclaré au banc. |
|
||||||
|
| R6 | **Qui écrit et maintient le code Rust**, et qui le relit ? | À désigner avant le Lot 0 ; si personne ne connaît Rust, prévoir 1 à 2 mois d'apprentissage, ou ne pas lancer la migration. |
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -438,6 +438,18 @@ CREATE TABLE IF NOT EXISTS "supplier_mail_logs" (
|
|||||||
CREATE INDEX IF NOT EXISTS idx_supplier_mail_logs_delivery ON supplier_mail_logs(delivery_id);
|
CREATE INDEX IF NOT EXISTS idx_supplier_mail_logs_delivery ON supplier_mail_logs(delivery_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_supplier_mail_logs_group ON supplier_mail_logs(group_id);
|
CREATE INDEX IF NOT EXISTS idx_supplier_mail_logs_group ON supplier_mail_logs(group_id);
|
||||||
|
|
||||||
|
-- Clés de l'API externe de rapprochement (empreinte SHA-256, jamais la clé en clair)
|
||||||
|
CREATE TABLE IF NOT EXISTS "external_api_keys" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"name" varchar(100) NOT NULL,
|
||||||
|
"key_prefix" varchar(16) NOT NULL,
|
||||||
|
"key_hash" varchar(64) NOT NULL UNIQUE,
|
||||||
|
"created_by" varchar,
|
||||||
|
"created_at" timestamp DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"last_used_at" timestamp,
|
||||||
|
"revoked_at" timestamp
|
||||||
|
);
|
||||||
|
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
-- FOREIGN KEY CONSTRAINTS
|
-- FOREIGN KEY CONSTRAINTS
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
-- Backfill rapprochement automatique
|
||||||
|
--
|
||||||
|
-- Contexte : l'endpoint POST /api/deliveries/:id/validate ne posait pas
|
||||||
|
-- reconciled = true pour les fournisseurs en rapprochement automatique
|
||||||
|
-- (automatic_reconciliation = true). Ces livraisons n'apparaissaient ni dans
|
||||||
|
-- l'onglet "Manuels" (exclues car fournisseur auto) ni dans "Validées"
|
||||||
|
-- (reconciled = false). Même cas pour les livraisons antérieures au passage
|
||||||
|
-- d'un fournisseur en mode automatique.
|
||||||
|
--
|
||||||
|
-- Applique le même critère que l'auto-validation côté serveur :
|
||||||
|
-- livraison livrée + numéro de BL renseigné.
|
||||||
|
|
||||||
|
UPDATE deliveries d
|
||||||
|
SET reconciled = true,
|
||||||
|
validated_at = COALESCE(d.validated_at, NOW())
|
||||||
|
FROM suppliers s
|
||||||
|
WHERE d.supplier_id = s.id
|
||||||
|
AND s.automatic_reconciliation = true
|
||||||
|
AND d.status = 'delivered'
|
||||||
|
AND d.bl_number IS NOT NULL
|
||||||
|
AND TRIM(d.bl_number) <> ''
|
||||||
|
AND (d.reconciled IS NULL OR d.reconciled = false);
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Database Migration Script
|
|
||||||
* Automatically runs database migrations for production deployment
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { Pool } = require('pg');
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
// Database connection
|
|
||||||
const pool = new Pool({
|
|
||||||
connectionString: process.env.DATABASE_URL,
|
|
||||||
ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false
|
|
||||||
});
|
|
||||||
|
|
||||||
async function runMigrations() {
|
|
||||||
console.log('🔄 Starting database migrations...');
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Create migrations table if it doesn't exist
|
|
||||||
await pool.query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS migrations (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
filename VARCHAR(255) UNIQUE NOT NULL,
|
|
||||||
executed_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
|
||||||
);
|
|
||||||
`);
|
|
||||||
console.log('✅ Migrations table ready');
|
|
||||||
|
|
||||||
// Get list of executed migrations
|
|
||||||
const { rows: executedMigrations } = await pool.query(
|
|
||||||
'SELECT filename FROM migrations ORDER BY executed_at'
|
|
||||||
);
|
|
||||||
const executedSet = new Set(executedMigrations.map(row => row.filename));
|
|
||||||
|
|
||||||
// Read migration files
|
|
||||||
const migrationsDir = path.join(__dirname);
|
|
||||||
const files = fs.readdirSync(migrationsDir)
|
|
||||||
.filter(file => file.endsWith('.sql'))
|
|
||||||
.sort();
|
|
||||||
|
|
||||||
console.log(`📁 Found ${files.length} migration files`);
|
|
||||||
|
|
||||||
let appliedCount = 0;
|
|
||||||
|
|
||||||
for (const file of files) {
|
|
||||||
if (executedSet.has(file)) {
|
|
||||||
console.log(`⏭️ Skipping ${file} (already executed)`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`🔄 Executing migration: ${file}`);
|
|
||||||
|
|
||||||
const filePath = path.join(migrationsDir, file);
|
|
||||||
const sql = fs.readFileSync(filePath, 'utf8');
|
|
||||||
|
|
||||||
// Execute migration in a transaction
|
|
||||||
const client = await pool.connect();
|
|
||||||
try {
|
|
||||||
await client.query('BEGIN');
|
|
||||||
await client.query(sql);
|
|
||||||
await client.query(
|
|
||||||
'INSERT INTO migrations (filename) VALUES ($1)',
|
|
||||||
[file]
|
|
||||||
);
|
|
||||||
await client.query('COMMIT');
|
|
||||||
console.log(`✅ Successfully executed: ${file}`);
|
|
||||||
appliedCount++;
|
|
||||||
} catch (error) {
|
|
||||||
await client.query('ROLLBACK');
|
|
||||||
throw new Error(`Failed to execute ${file}: ${error.message}`);
|
|
||||||
} finally {
|
|
||||||
client.release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (appliedCount === 0) {
|
|
||||||
console.log('✅ All migrations are up to date');
|
|
||||||
} else {
|
|
||||||
console.log(`✅ Successfully applied ${appliedCount} migration(s)`);
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Migration failed:', error.message);
|
|
||||||
process.exit(1);
|
|
||||||
} finally {
|
|
||||||
await pool.end();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run migrations if this file is executed directly
|
|
||||||
if (require.main === module) {
|
|
||||||
runMigrations();
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { runMigrations };
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
// Script de migration production pour créer la table announcements
|
|
||||||
// Usage: node migrations/production_fix_announcements.js
|
|
||||||
|
|
||||||
import { drizzle } from 'drizzle-orm/node-postgres';
|
|
||||||
import pg from 'pg';
|
|
||||||
import { fileURLToPath } from 'url';
|
|
||||||
import { dirname, join } from 'path';
|
|
||||||
import { readFileSync } from 'fs';
|
|
||||||
|
|
||||||
const { Pool } = pg;
|
|
||||||
|
|
||||||
async function createAnnouncementsTable() {
|
|
||||||
const pool = new Pool({
|
|
||||||
connectionString: process.env.DATABASE_URL,
|
|
||||||
});
|
|
||||||
|
|
||||||
const db = drizzle(pool);
|
|
||||||
|
|
||||||
try {
|
|
||||||
console.log('🔄 Création de la table announcements...');
|
|
||||||
|
|
||||||
// SQL pour créer la table announcements
|
|
||||||
const sql = `
|
|
||||||
-- Création de la table announcements
|
|
||||||
CREATE TABLE IF NOT EXISTS announcements (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
title VARCHAR(255) NOT NULL,
|
|
||||||
content TEXT NOT NULL,
|
|
||||||
priority VARCHAR(20) NOT NULL DEFAULT 'normal' CHECK (priority IN ('normal', 'important', 'urgent')),
|
|
||||||
author_id VARCHAR(255) NOT NULL,
|
|
||||||
group_id INTEGER,
|
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
||||||
CONSTRAINT announcements_author_id_fkey FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
CONSTRAINT announcements_group_id_fkey FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Création des index
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_announcements_priority ON announcements(priority);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_announcements_created_at ON announcements(created_at DESC);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_announcements_author_id ON announcements(author_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_announcements_group_id ON announcements(group_id);
|
|
||||||
|
|
||||||
-- Commentaires
|
|
||||||
COMMENT ON TABLE announcements IS 'Admin-managed announcements/information system';
|
|
||||||
COMMENT ON COLUMN announcements.priority IS 'Priority level: normal, important, urgent';
|
|
||||||
COMMENT ON COLUMN announcements.group_id IS 'NULL for global announcements, specific group_id for store-specific announcements';
|
|
||||||
`;
|
|
||||||
|
|
||||||
await pool.query(sql);
|
|
||||||
|
|
||||||
console.log('✅ Table announcements créée avec succès!');
|
|
||||||
|
|
||||||
// Vérification que la table existe
|
|
||||||
const checkResult = await pool.query(`
|
|
||||||
SELECT EXISTS (
|
|
||||||
SELECT FROM information_schema.tables
|
|
||||||
WHERE table_schema = 'public'
|
|
||||||
AND table_name = 'announcements'
|
|
||||||
);
|
|
||||||
`);
|
|
||||||
|
|
||||||
if (checkResult.rows[0].exists) {
|
|
||||||
console.log('✅ Vérification: La table announcements existe bien');
|
|
||||||
|
|
||||||
// Afficher la structure de la table
|
|
||||||
const structure = await pool.query(`
|
|
||||||
SELECT column_name, data_type, is_nullable, column_default
|
|
||||||
FROM information_schema.columns
|
|
||||||
WHERE table_name = 'announcements'
|
|
||||||
ORDER BY ordinal_position;
|
|
||||||
`);
|
|
||||||
|
|
||||||
console.log('📋 Structure de la table:');
|
|
||||||
structure.rows.forEach(row => {
|
|
||||||
console.log(` ${row.column_name}: ${row.data_type}${row.is_nullable === 'NO' ? ' NOT NULL' : ''}`);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
console.log('❌ Erreur: La table announcements n\'a pas été créée');
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Erreur lors de la création de la table:', error);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
await pool.end();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exécution
|
|
||||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
||||||
createAnnouncementsTable()
|
|
||||||
.then(() => {
|
|
||||||
console.log('🎉 Migration terminée avec succès');
|
|
||||||
process.exit(0);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('💥 Échec de la migration:', error);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export { createAnnouncementsTable };
|
|
||||||
Generated
+87
-2091
File diff suppressed because it is too large.
Load diff
+8
-51
@@ -1,19 +1,20 @@
|
|||||||
{
|
{
|
||||||
"name": "rest-express",
|
"name": "logiflow",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "NODE_ENV=development tsx server/index.ts",
|
"dev": "vite",
|
||||||
"build": "vite build && tsc --project tsconfig.server.json",
|
"dev:server": "cargo run --manifest-path server-rs/Cargo.toml",
|
||||||
"start": "NODE_ENV=production node dist/server/index.js",
|
"build": "vite build && cargo build --release --manifest-path server-rs/Cargo.toml",
|
||||||
|
"start": "./server-rs/target/release/logiflow",
|
||||||
"check": "tsc",
|
"check": "tsc",
|
||||||
"db:push": "drizzle-kit push"
|
"db:push": "drizzle-kit push",
|
||||||
|
"gen:rust-schema": "tsx scripts/gen-rust-schema.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^3.10.0",
|
"@hookform/resolvers": "^3.10.0",
|
||||||
"@jridgewell/trace-mapping": "^0.3.25",
|
"@jridgewell/trace-mapping": "^0.3.25",
|
||||||
"@neondatabase/serverless": "^0.10.4",
|
|
||||||
"@radix-ui/react-accordion": "^1.2.4",
|
"@radix-ui/react-accordion": "^1.2.4",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.7",
|
"@radix-ui/react-alert-dialog": "^1.1.7",
|
||||||
"@radix-ui/react-aspect-ratio": "^1.1.3",
|
"@radix-ui/react-aspect-ratio": "^1.1.3",
|
||||||
@@ -42,48 +43,20 @@
|
|||||||
"@radix-ui/react-toggle-group": "^1.1.3",
|
"@radix-ui/react-toggle-group": "^1.1.3",
|
||||||
"@radix-ui/react-tooltip": "^1.2.0",
|
"@radix-ui/react-tooltip": "^1.2.0",
|
||||||
"@tanstack/react-query": "^5.60.5",
|
"@tanstack/react-query": "^5.60.5",
|
||||||
"@types/bcrypt": "^6.0.0",
|
|
||||||
"@types/busboy": "^1.5.4",
|
|
||||||
"@types/cookie-parser": "^1.4.10",
|
|
||||||
"@types/form-data": "^2.2.1",
|
|
||||||
"@types/memoizee": "^0.4.12",
|
|
||||||
"@types/multer": "^2.0.0",
|
|
||||||
"@types/pg": "^8.15.4",
|
"@types/pg": "^8.15.4",
|
||||||
"@types/validator": "^13.15.10",
|
|
||||||
"bcrypt": "^6.0.0",
|
|
||||||
"busboy": "^1.6.0",
|
|
||||||
"caniuse-lite": "^1.0.30001727",
|
"caniuse-lite": "^1.0.30001727",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"connect-pg-simple": "^10.0.0",
|
|
||||||
"cookie-parser": "^1.4.7",
|
|
||||||
"csrf-csrf": "^4.0.3",
|
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"drizzle-orm": "^0.39.1",
|
"drizzle-orm": "^0.39.1",
|
||||||
"drizzle-zod": "^0.7.0",
|
"drizzle-zod": "^0.7.0",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"encoding": "^0.1.13",
|
|
||||||
"express": "^4.21.2",
|
|
||||||
"express-rate-limit": "^7.5.1",
|
|
||||||
"express-session": "^1.18.1",
|
|
||||||
"form-data": "^4.0.4",
|
|
||||||
"framer-motion": "^11.13.1",
|
"framer-motion": "^11.13.1",
|
||||||
"iconv-lite": "^0.6.3",
|
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"jsbarcode": "^3.12.1",
|
"jsbarcode": "^3.12.1",
|
||||||
"lucide-react": "^0.453.0",
|
"lucide-react": "^0.453.0",
|
||||||
"memoizee": "^0.4.17",
|
|
||||||
"memorystore": "^1.6.7",
|
|
||||||
"multer": "^2.0.2",
|
|
||||||
"nanoid": "^5.1.5",
|
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"node-cron": "^4.2.1",
|
|
||||||
"node-fetch": "^3.3.2",
|
|
||||||
"nodemailer": "^9.0.5",
|
|
||||||
"openid-client": "^6.7.1",
|
|
||||||
"passport": "^0.7.0",
|
|
||||||
"passport-local": "^1.0.0",
|
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-day-picker": "^8.10.1",
|
"react-day-picker": "^8.10.1",
|
||||||
@@ -95,41 +68,25 @@
|
|||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"tw-animate-css": "^1.2.5",
|
"tw-animate-css": "^1.2.5",
|
||||||
"validator": "^13.15.26",
|
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
"wouter": "^3.3.5",
|
"wouter": "^3.3.5",
|
||||||
"ws": "^8.18.0",
|
"zod": "^3.24.2"
|
||||||
"xlsx": "^0.18.5",
|
|
||||||
"zod": "^3.24.2",
|
|
||||||
"zod-validation-error": "^3.4.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@replit/vite-plugin-cartographer": "^0.2.7",
|
"@replit/vite-plugin-cartographer": "^0.2.7",
|
||||||
"@replit/vite-plugin-runtime-error-modal": "^0.0.3",
|
"@replit/vite-plugin-runtime-error-modal": "^0.0.3",
|
||||||
"@tailwindcss/typography": "^0.5.15",
|
"@tailwindcss/typography": "^0.5.15",
|
||||||
"@tailwindcss/vite": "^4.1.3",
|
"@tailwindcss/vite": "^4.1.3",
|
||||||
"@types/connect-pg-simple": "^7.0.3",
|
|
||||||
"@types/express": "^4.17.21",
|
|
||||||
"@types/express-session": "^1.18.0",
|
|
||||||
"@types/node": "^20.16.11",
|
"@types/node": "^20.16.11",
|
||||||
"@types/node-fetch": "^2.6.13",
|
|
||||||
"@types/nodemailer": "^8.0.1",
|
|
||||||
"@types/passport": "^1.0.16",
|
|
||||||
"@types/passport-local": "^1.0.38",
|
|
||||||
"@types/react": "^18.3.27",
|
"@types/react": "^18.3.27",
|
||||||
"@types/react-dom": "^18.3.7",
|
"@types/react-dom": "^18.3.7",
|
||||||
"@types/ws": "^8.5.13",
|
|
||||||
"@vitejs/plugin-react": "^4.3.2",
|
"@vitejs/plugin-react": "^4.3.2",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"drizzle-kit": "^0.30.4",
|
"drizzle-kit": "^0.30.4",
|
||||||
"esbuild": "^0.25.0",
|
|
||||||
"postcss": "^8.4.47",
|
"postcss": "^8.4.47",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"tsx": "^4.19.1",
|
"tsx": "^4.19.1",
|
||||||
"typescript": "5.6.3",
|
"typescript": "5.6.3",
|
||||||
"vite": "^5.4.19"
|
"vite": "^5.4.19"
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"bufferutil": "^4.0.8"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -21,7 +21,7 @@ The platform is built with a clear separation of concerns, utilizing a modern fu
|
|||||||
|
|
||||||
**Technical Implementations & Design Patterns:**
|
**Technical Implementations & Design Patterns:**
|
||||||
- **Frontend:** React.js for interactive user interfaces, with React Query for data fetching and state management.
|
- **Frontend:** React.js for interactive user interfaces, with React Query for data fetching and state management.
|
||||||
- **Backend:** Node.js Express (ESM) provides a robust API layer. Data validation is performed using Zod schemas.
|
- **Backend:** Rust server (axum, tokio, sqlx) in `server-rs/` serves the API and the built UI, runs startup migrations and background jobs. Validation keeps the Zod rules and messages of `shared/schema.ts`, from which `server-rs/src/schema.rs` is generated.
|
||||||
- **Database:** PostgreSQL as the primary data store, with Drizzle ORM facilitating interaction and database migrations ensuring schema consistency.
|
- **Database:** PostgreSQL as the primary data store, with Drizzle ORM facilitating interaction and database migrations ensuring schema consistency.
|
||||||
- **Authentication:** Secure role-based access control (RBAC) for granular permissions.
|
- **Authentication:** Secure role-based access control (RBAC) for granular permissions.
|
||||||
- **Multi-tenancy:** Supports multi-tenant operations with group and permission management, ensuring data isolation between stores/groups.
|
- **Multi-tenancy:** Supports multi-tenant operations with group and permission management, ensuring data isolation between stores/groups.
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Banc de comparaison Node / Rust
|
||||||
|
|
||||||
|
Le serveur Node a quitté le dépôt ; il reste la référence du comportement attendu. Le banc rejoue les mêmes requêtes sur le serveur Node de référence (construit depuis le commit `REFERENCE_NODE_COMMIT`) et sur le serveur Rust et compare, requête par requête : code HTTP, corps (valeurs et ordre des listes), en-têtes utiles (`Content-Type`, `Cache-Control`, `Location`, `Content-Disposition`, attributs des cookies), état des tables après les écritures, et appels faits aux services externes. Tout changement du serveur Rust qui ne doit pas changer son comportement passe par lui ; un changement voulu y est déclaré comme écart (`deviation`).
|
||||||
|
|
||||||
|
## Préparer
|
||||||
|
|
||||||
|
1. PostgreSQL local, avec un rôle qui peut créer des bases :
|
||||||
|
```bash
|
||||||
|
sudo -u postgres psql -c "CREATE ROLE logiflow_admin LOGIN SUPERUSER PASSWORD 'bench'"
|
||||||
|
```
|
||||||
|
Autre adresse : variable `BENCH_PG_URL` (par défaut `postgres://logiflow_admin:bench@localhost:5432`).
|
||||||
|
2. Construire le serveur Node de référence : `scripts/bench/reference-node.sh`. Il en fait une copie de travail à part (`.bench-reference/`, ignorée par git) au commit `REFERENCE_NODE_COMMIT`, avec son interface ; à refaire seulement quand ce commit change. Autre dossier : variable `BENCH_NODE_DIR`.
|
||||||
|
3. Construire le serveur Rust : `cargo build --release --manifest-path server-rs/Cargo.toml` (autre binaire : `RUST_BIN`).
|
||||||
|
4. Créer la base modèle `bench_seed` : `scripts/bench/setup.sh`. Elle contient le schéma de production (`schema.sh`), les index créés au premier démarrage du Node de référence et les données de `seed.ts` : 3 magasins, 5 comptes, quelques lignes par table, datées par rapport au jour du passage.
|
||||||
|
|
||||||
|
## Lancer
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx tsx scripts/bench/compare.ts # Node de référence contre Rust
|
||||||
|
npx tsx scripts/bench/compare.ts --only dlc,tasks # seulement ces modules
|
||||||
|
npx tsx scripts/bench/compare.ts --mode node # Node contre Node : le banc est-il stable ?
|
||||||
|
```
|
||||||
|
|
||||||
|
Les deux serveurs servent la même interface (celle de la référence). Plusieurs bancs en parallèle : `BENCH_ID` (bases distinctes) et `BENCH_PORT_BASE` (ports distincts).
|
||||||
|
|
||||||
|
Le résultat liste les écarts, puis un tableau par module. Les réponses complètes et les écarts sont écrits dans le dossier de journaux affiché au début (`gauche.json`, `droite.json`, `ecarts.json`), avec les journaux des serveurs.
|
||||||
|
|
||||||
|
## Ajouter des requêtes
|
||||||
|
|
||||||
|
Une liste par module dans `cases/` (lectures et scénarios d'écriture, voir `types.ts`), déclarée dans `cases/index.ts`.
|
||||||
|
|
||||||
|
- **Lecture** : rejouée pour les 5 comptes et sans session, sur la même base.
|
||||||
|
- **Écriture** : base remise à zéro avant chaque compte ; les tables listées sont comparées à la fin.
|
||||||
|
- **Gabarits** : `{{today}}`, `{{today+3}}`, `{{year-1}}`, `{{month}}` ; `{{0.id}}` reprend l'`id` de la réponse de l'étape 0.
|
||||||
|
- Les horodatages proches du passage sont remplacés par `<maintenant>` ; `ignore` masque d'autres champs propres à chaque passage.
|
||||||
|
- **Écart voulu** (faille fermée, décision du plan) : `deviation: { fields, reason }` retire ces champs des deux côtés avant la comparaison ; le banc rappelle la raison en fin de rapport.
|
||||||
|
|
||||||
|
## Comptes et services simulés
|
||||||
|
|
||||||
|
| Compte | Rôle | Magasins |
|
||||||
|
|---|---|---|
|
||||||
|
| `admin` | admin | aucun (voit tout) |
|
||||||
|
| `directeur` | directeur | Nancy, Metz |
|
||||||
|
| `manager` | manager | Nancy |
|
||||||
|
| `employe` | employee | Nancy |
|
||||||
|
| `sans-magasin` | employee | aucun |
|
||||||
|
|
||||||
|
Plus `anonyme` (sans session) et `api` (clé de l'API externe). Mots de passe et clé : `config.ts`.
|
||||||
|
|
||||||
|
`stub.ts` simule NocoDB (table de factures), les webhooks (avoirs, BAP) et un serveur SMTP ; les magasins du banc pointent vers lui. La météo (Visual Crossing) et ffnancy, dont les adresses sont écrites dans le code, ne sont pas rejouées.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
8f4d799016e6dcd75cbef17ad27193f47b79300a
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
// Lot 5 : connexion et session, utilisateurs, magasins, sauvegardes, paramètres
|
||||||
|
// généraux, diagnostic du schéma (docs/PLAN-MIGRATION-RUST.md). Les lectures
|
||||||
|
// de /api/user, /api/default-credentials-check (socle.ts, « auth »),
|
||||||
|
// /api/users et /api/groups (referentiel.ts) sont déjà au banc.
|
||||||
|
import type { ReadCase, WriteScenario } from "../types";
|
||||||
|
import { LOGGED_ACCOUNTS } from "../types";
|
||||||
|
import { STUB_SMTP_PORT } from "../config";
|
||||||
|
|
||||||
|
// Le hash renvoyé par Node après POST / PUT /api/users (sel aléatoire) n'est pas
|
||||||
|
// renvoyé par Rust : écart voulu, retiré de la comparaison (réponses et table users)
|
||||||
|
const PASSWORD_HASH = {
|
||||||
|
fields: ["password"],
|
||||||
|
reason: "USERS-02 : l'empreinte du mot de passe n'est plus renvoyée après création ou modification d'un compte",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Session d'une connexion : identifiant et échéance propres à chaque passage
|
||||||
|
// originalMaxAge : express-session le recalcule à la milliseconde près quand il
|
||||||
|
// réenregistre une session (86399999 ou 86400000 selon l'instant)
|
||||||
|
const SESSION_IGNORE = ["sid", "expire", "expires", "originalMaxAge"];
|
||||||
|
|
||||||
|
const BROWSER_ACCEPT = "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8";
|
||||||
|
|
||||||
|
// Magasin dont le serveur SMTP est le service simulé du banc
|
||||||
|
const SMTP = {
|
||||||
|
smtpEnabled: true,
|
||||||
|
smtpHost: "127.0.0.1",
|
||||||
|
smtpPort: STUB_SMTP_PORT,
|
||||||
|
smtpSecure: false,
|
||||||
|
smtpUser: "toul@banc.test",
|
||||||
|
smtpSenderEmail: "toul@banc.test",
|
||||||
|
smtpSenderName: "Magasin Toul",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const reads: ReadCase[] = [
|
||||||
|
{ module: "backups", path: "/api/backups" },
|
||||||
|
{ module: "backups", path: "/api/backups/backup_banc_1.sql/download" },
|
||||||
|
{ module: "backups", path: "/api/backups/backup_banc_500.sql/download", accounts: ["admin", "manager"] },
|
||||||
|
{ module: "backups", path: "/api/backups/absente.sql/download", accounts: ["admin", "manager"] },
|
||||||
|
{ module: "utilities", path: "/api/utilities" },
|
||||||
|
{ module: "admin", path: "/api/debug/log-schema" },
|
||||||
|
{ module: "admin", path: "/api/debug/download-schema" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const writes: WriteScenario[] = [
|
||||||
|
// ---------- Session ----------
|
||||||
|
{
|
||||||
|
module: "session",
|
||||||
|
name: "connexion réussie : réponse, cookie et session enregistrée",
|
||||||
|
accounts: ["anonyme"],
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/login", body: { username: "directeur", password: "banc-directeur" } },
|
||||||
|
{ method: "GET", path: "/api/user" },
|
||||||
|
],
|
||||||
|
tables: ["sessions"],
|
||||||
|
ignore: SESSION_IGNORE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "session",
|
||||||
|
name: "connexion par formulaire ou par l'adresse",
|
||||||
|
accounts: ["anonyme"],
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/login", body: { username: "employe", password: "banc-employe" }, bodyType: "form" },
|
||||||
|
{ method: "POST", path: "/api/login?username=manager&password=banc-manager" },
|
||||||
|
{ method: "POST", path: "/api/login?username=sansmagasin", body: { password: "banc-sans-magasin" } },
|
||||||
|
],
|
||||||
|
tables: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "session",
|
||||||
|
name: "connexion refusée : identifiants mal formés",
|
||||||
|
accounts: ["anonyme"],
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/login", body: { username: "manager", password: 123 } },
|
||||||
|
{ method: "POST", path: "/api/login", body: { username: "manager", password: true } },
|
||||||
|
{ method: "POST", path: "/api/login", body: { username: "manager", password: ["banc-manager"] } },
|
||||||
|
{ method: "POST", path: "/api/login", body: { username: ["manager"], password: "banc-manager" } },
|
||||||
|
{ method: "POST", path: "/api/login", body: { username: "manager", password: "" } },
|
||||||
|
{ method: "POST", path: "/api/login", body: { username: "MANAGER", password: "banc-manager" } },
|
||||||
|
{ method: "POST", path: "/api/login", body: { username: 0, password: "x" } },
|
||||||
|
{ method: "POST", path: "/api/login", body: { username: 12, password: "x" } },
|
||||||
|
{ method: "POST", path: "/api/login", body: ["manager", "banc-manager"] },
|
||||||
|
{ method: "POST", path: "/api/login?username=manager&username=directeur&password=banc-manager" },
|
||||||
|
{ method: "POST", path: "/api/login", body: { username: "admin", password: "admin" } },
|
||||||
|
],
|
||||||
|
tables: ["sessions"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "session",
|
||||||
|
name: "connexion par-dessus une session ouverte",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/login", body: { username: "sansmagasin", password: "banc-sans-magasin" } },
|
||||||
|
// L'ancienne session est détruite : le cookie du compte ne vaut plus rien
|
||||||
|
{ method: "GET", path: "/api/user" },
|
||||||
|
],
|
||||||
|
tables: ["sessions"],
|
||||||
|
ignore: SESSION_IGNORE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "session",
|
||||||
|
name: "déconnexion (GET et POST, selon Accept) puis lecture de l'utilisateur",
|
||||||
|
accounts: [...LOGGED_ACCOUNTS, "anonyme"],
|
||||||
|
steps: [
|
||||||
|
{ method: "GET", path: "/api/logout", headers: { accept: BROWSER_ACCEPT } },
|
||||||
|
{ method: "GET", path: "/api/user" },
|
||||||
|
{ method: "POST", path: "/api/logout", headers: { accept: "application/json" } },
|
||||||
|
{ method: "POST", path: "/api/logout" },
|
||||||
|
],
|
||||||
|
tables: ["sessions"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Session ouverte par la connexion, lue par l'adresse de test SMTP
|
||||||
|
module: "session",
|
||||||
|
name: "session acceptée par le test SMTP d'un magasin",
|
||||||
|
steps: [{ method: "POST", path: "/api/groups/1/test-smtp" }],
|
||||||
|
tables: [],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------- Utilisateurs ----------
|
||||||
|
{
|
||||||
|
module: "users",
|
||||||
|
name: "créer, modifier, affecter, retirer et supprimer un utilisateur",
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: "/api/users",
|
||||||
|
body: { id: "bench_nouveau", username: "nouveau", email: "nouveau@banc.test", firstName: "Noé", lastName: "Nouveau", password: "banc-nouveau", role: "employee" },
|
||||||
|
},
|
||||||
|
{ method: "PUT", path: "/api/users/bench_nouveau", body: { firstName: "Noémie", lastName: "", email: "", role: "manager", password: "banc-nouveau-2" } },
|
||||||
|
{ method: "POST", path: "/api/users/bench_nouveau/groups", body: { groupId: 2 } },
|
||||||
|
{ method: "POST", path: "/api/users/bench_nouveau/groups", body: { groupId: 3 } },
|
||||||
|
{ method: "DELETE", path: "/api/users/bench_nouveau/groups/2" },
|
||||||
|
{ method: "GET", path: "/api/users" },
|
||||||
|
{ method: "PUT", path: "/api/users/bench_employee", body: { password: "banc-employe-2" } },
|
||||||
|
{ method: "DELETE", path: "/api/users/bench_nouveau" },
|
||||||
|
{ method: "GET", path: "/api/users" },
|
||||||
|
// Le mot de passe modifié est vérifiable (en dernier : la connexion
|
||||||
|
// remplace la session du compte qui rejoue le scénario)
|
||||||
|
{ method: "POST", path: "/api/login", body: { username: "employe", password: "banc-employe-2" } },
|
||||||
|
],
|
||||||
|
tables: ["users", "user_groups"],
|
||||||
|
deviation: PASSWORD_HASH,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "users",
|
||||||
|
name: "créer un utilisateur sans identifiant ni adresse",
|
||||||
|
accounts: ["admin", "manager"],
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/users", body: { username: "sansid", password: "banc-sans-id", email: "" } },
|
||||||
|
{ method: "POST", path: "/api/users", body: { username: "blanc", password: "x", email: " " } },
|
||||||
|
{ method: "POST", path: "/api/users", body: { id: "", username: "sansid2", password: "x", email: null, firstName: "", role: "directeur" } },
|
||||||
|
// Le mot de passe du compte créé est vérifiable
|
||||||
|
{ method: "POST", path: "/api/login", body: { username: "sansid", password: "banc-sans-id" } },
|
||||||
|
],
|
||||||
|
tables: ["users"],
|
||||||
|
// Identifiant généré : manual_<millisecondes>_<aléatoire>
|
||||||
|
ignore: ["id"],
|
||||||
|
deviation: PASSWORD_HASH,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "users",
|
||||||
|
name: "utilisateurs invalides, en double ou introuvables",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/users", body: {} },
|
||||||
|
{ method: "POST", path: "/api/users", body: { username: "", password: "", email: 5, role: "chef" } },
|
||||||
|
{ method: "POST", path: "/api/users", body: { username: "a", password: "b", email: "pas-un-courriel", firstName: null } },
|
||||||
|
{ method: "POST", path: "/api/users", body: { username: "manager", password: "x" } },
|
||||||
|
{ method: "POST", path: "/api/users", body: { username: "autre", password: "x", email: "manager@banc.test" } },
|
||||||
|
{ method: "POST", path: "/api/users", body: { id: "bench_manager", username: "autre2", password: "x" } },
|
||||||
|
{ method: "POST", path: "/api/users", body: ["x"] },
|
||||||
|
{ method: "PUT", path: "/api/users/bench_employee", body: { role: "chef", firstName: 5 } },
|
||||||
|
{ method: "PUT", path: "/api/users/bench_employee", body: { username: "manager" } },
|
||||||
|
{ method: "PUT", path: "/api/users/bench_employee", body: { email: "directeur@banc.test" } },
|
||||||
|
{ method: "PUT", path: "/api/users/inconnu", body: { firstName: "X" } },
|
||||||
|
// Noms effacés ; adresse vide ignorée (non effacée, comme Node)
|
||||||
|
{ method: "PUT", path: "/api/users/bench_employee", body: { firstName: null, lastName: "", email: "" } },
|
||||||
|
{ method: "DELETE", path: "/api/users/admin_local" },
|
||||||
|
{ method: "DELETE", path: "/api/users/bench_employee" },
|
||||||
|
{ method: "POST", path: "/api/users/bench_manager/groups", body: { groupId: "2" } },
|
||||||
|
{ method: "POST", path: "/api/users/bench_manager/groups", body: {} },
|
||||||
|
{ method: "DELETE", path: "/api/users/bench_manager/groups/abc" },
|
||||||
|
{ method: "DELETE", path: "/api/users/bench_manager/groups/1" },
|
||||||
|
// Mot de passe vide enregistré tel quel (défaut de Node) : connexion
|
||||||
|
// impossible (en dernier : une connexion réussie remplacerait la session)
|
||||||
|
{ method: "PUT", path: "/api/users/bench_sans_magasin", body: { password: "" } },
|
||||||
|
{ method: "POST", path: "/api/login", body: { username: "sansmagasin", password: "banc-sans-magasin" } },
|
||||||
|
],
|
||||||
|
tables: ["users", "user_groups"],
|
||||||
|
deviation: PASSWORD_HASH,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------- Magasins ----------
|
||||||
|
{
|
||||||
|
module: "groups",
|
||||||
|
name: "créer, modifier, tester et supprimer un magasin",
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: "/api/groups",
|
||||||
|
body: { name: "Magasin Toul", color: "#7B1FA2", address: "", phone: "", logo: "", nocodbConfigId: null, ...SMTP, smtpPassword: "secret-toul" },
|
||||||
|
},
|
||||||
|
// Node relit le mot de passe chiffré par l'autre serveur
|
||||||
|
{ method: "POST", path: "/api/groups/{{0.id}}/test-smtp" },
|
||||||
|
{ method: "PUT", path: "/api/groups/{{0.id}}", body: { name: "Magasin de Toul", phone: "03 83 00 00 05", smtpPassword: "" } },
|
||||||
|
{ method: "PUT", path: "/api/groups/{{0.id}}", body: { smtpPassword: "nouveau-secret", smtpPort: String(STUB_SMTP_PORT) } },
|
||||||
|
{ method: "POST", path: "/api/groups/{{0.id}}/test-smtp" },
|
||||||
|
{ method: "PUT", path: "/api/groups/{{0.id}}", body: { smtpSenderEmail: "", smtpPort: null } },
|
||||||
|
{ method: "POST", path: "/api/groups/{{0.id}}/test-smtp" },
|
||||||
|
{ method: "GET", path: "/api/groups" },
|
||||||
|
{ method: "DELETE", path: "/api/groups/{{0.id}}" },
|
||||||
|
],
|
||||||
|
tables: ["groups"],
|
||||||
|
// Chiffré avec un vecteur aléatoire
|
||||||
|
ignore: ["smtp_password"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "groups",
|
||||||
|
name: "tester le SMTP : configuration incomplète, serveur injoignable, magasin introuvable",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/groups/2/test-smtp" },
|
||||||
|
{ method: "POST", path: "/api/groups/999/test-smtp" },
|
||||||
|
{ method: "POST", path: "/api/groups/abc/test-smtp" },
|
||||||
|
// Port fermé : connexion refusée (modification réservée à l'admin et au manager)
|
||||||
|
{ method: "PUT", path: "/api/groups/2", body: { smtpHost: "127.0.0.1", smtpPort: 1, smtpSenderEmail: "metz@banc.test" } },
|
||||||
|
{ method: "POST", path: "/api/groups/2/test-smtp" },
|
||||||
|
],
|
||||||
|
tables: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "groups",
|
||||||
|
name: "magasins invalides, introuvables ou encore utilisés",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/groups", body: {} },
|
||||||
|
{ method: "POST", path: "/api/groups", body: { name: "X", smtpSenderEmail: 5 } },
|
||||||
|
{ method: "POST", path: "/api/groups", body: { name: "X", smtpSenderEmail: "abc", smtpPort: "0" } },
|
||||||
|
{ method: "POST", path: "/api/groups", body: { name: "X", smtpPort: "abc", phone: 12 } },
|
||||||
|
{ method: "PUT", path: "/api/groups/1", body: { name: 5 } },
|
||||||
|
{ method: "PUT", path: "/api/groups/999", body: { name: "Fantôme" } },
|
||||||
|
{ method: "PUT", path: "/api/groups/abc", body: { name: "X" } },
|
||||||
|
// Un manager modifie un magasin qui n'est pas le sien (comme Node)
|
||||||
|
{ method: "PUT", path: "/api/groups/2", body: { color: "#000000", smtpPassword: null } },
|
||||||
|
{ method: "DELETE", path: "/api/groups/1" },
|
||||||
|
{ method: "DELETE", path: "/api/groups/999" },
|
||||||
|
{ method: "DELETE", path: "/api/groups/abc" },
|
||||||
|
],
|
||||||
|
tables: ["groups"],
|
||||||
|
ignore: ["smtp_password"],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------- Sauvegardes ----------
|
||||||
|
{
|
||||||
|
module: "backups",
|
||||||
|
name: "créer une sauvegarde (pg_dump), ménage des anciennes",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/backups" },
|
||||||
|
{ method: "GET", path: "/api/backups" },
|
||||||
|
{ method: "DELETE", path: "/api/backups/{{0.filename}}" },
|
||||||
|
{ method: "GET", path: "/api/backups" },
|
||||||
|
],
|
||||||
|
// Les deux plus anciennes (sans fichier) ont été supprimées par le ménage
|
||||||
|
tables: ["database_backups"],
|
||||||
|
// Identifiant aléatoire, nom horodaté, taille du fichier pg_dump
|
||||||
|
ignore: ["id", "filename", "size"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "backups",
|
||||||
|
name: "supprimer une sauvegarde et son fichier",
|
||||||
|
steps: [
|
||||||
|
{ method: "DELETE", path: "/api/backups/backup_banc_500.sql" },
|
||||||
|
{ method: "GET", path: "/api/backups/backup_banc_500.sql/download" },
|
||||||
|
{ method: "DELETE", path: "/api/backups/backup_banc_500.sql" },
|
||||||
|
{ method: "DELETE", path: "/api/backups/absente.sql" },
|
||||||
|
{ method: "GET", path: "/api/backups" },
|
||||||
|
],
|
||||||
|
tables: ["database_backups"],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------- Paramètres généraux ----------
|
||||||
|
{
|
||||||
|
module: "utilities",
|
||||||
|
name: "modifier les paramètres généraux",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/utilities", body: { salesAnalysisUrl: "https://analyse.banc.test/nouveau" } },
|
||||||
|
{ method: "POST", path: "/api/utilities", body: { automaticBackupsEnabled: true, autre: 1 } },
|
||||||
|
{ method: "POST", path: "/api/utilities", body: { salesAnalysisUrl: null } },
|
||||||
|
{ method: "GET", path: "/api/utilities" },
|
||||||
|
],
|
||||||
|
tables: ["utilities"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "utilities",
|
||||||
|
name: "paramètres généraux invalides",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/utilities", body: { salesAnalysisUrl: 5 } },
|
||||||
|
{ method: "POST", path: "/api/utilities", body: { salesAnalysisUrl: "x".repeat(501), automaticBackupsEnabled: "oui" } },
|
||||||
|
{ method: "POST", path: "/api/utilities", body: [] },
|
||||||
|
],
|
||||||
|
tables: ["utilities"],
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
// Lot 4, commandes et livraisons : commandes fournisseurs, livraisons (validation,
|
||||||
|
// contrôle, rapprochement automatique, échéance relue dans NocoDB), API externe
|
||||||
|
// de rapprochement (/api/ext/v1, clé d'API) et relais ffnancy (sans réseau : seul
|
||||||
|
// le refus sans session est rejoué). NocoDB est simulé par stub.ts.
|
||||||
|
import { BENCH_API_KEY } from "../config";
|
||||||
|
import type { AccountKey, ReadCase, WriteScenario } from "../types";
|
||||||
|
|
||||||
|
// Clé d'API (en-tête X-API-Key), et deux sessions qui n'y donnent pas accès
|
||||||
|
const EXT_ACCOUNTS: AccountKey[] = ["api", "anonyme", "admin"];
|
||||||
|
|
||||||
|
// Durée de vie des entrées du cache des factures : relative à l'heure du passage
|
||||||
|
const CACHE_IGNORE = ["expires_at"];
|
||||||
|
|
||||||
|
const ext = (path: string): ReadCase => ({ module: "external-api-v1", path: `/api/ext/v1${path}`, accounts: EXT_ACCOUNTS });
|
||||||
|
|
||||||
|
export const reads: ReadCase[] = [
|
||||||
|
// ---------- Commandes (Orders.tsx, Dashboard.tsx, Calendar.tsx, modales, mobile) ----------
|
||||||
|
{ module: "orders", path: "/api/orders" },
|
||||||
|
{ module: "orders", path: "/api/orders?storeId=1" },
|
||||||
|
{ module: "orders", path: "/api/orders?storeId=2" },
|
||||||
|
{ module: "orders", path: "/api/orders?storeId=3" },
|
||||||
|
{ module: "orders", path: "/api/orders?storeId=abc" },
|
||||||
|
{ module: "orders", path: "/api/orders?" },
|
||||||
|
{ module: "orders", path: "/api/orders?startDate={{today-30}}&endDate={{today+30}}" },
|
||||||
|
{ module: "orders", path: "/api/orders?startDate={{today-30}}&endDate={{today+30}}&storeId=1" },
|
||||||
|
{ module: "orders", path: "/api/orders?storeId=2&startDate={{today}}&endDate={{today}}" },
|
||||||
|
{ module: "orders", path: "/api/orders?startDate={{today-400}}&endDate={{today+400}}&storeId=2" },
|
||||||
|
{ module: "orders", path: "/api/orders?startDate={{today}}" },
|
||||||
|
{ module: "orders", path: "/api/orders?startDate=abc&endDate=xyz" },
|
||||||
|
{ module: "orders", path: "/api/orders/1" },
|
||||||
|
{ module: "orders", path: "/api/orders/2" },
|
||||||
|
{ module: "orders", path: "/api/orders/4" },
|
||||||
|
{ module: "orders", path: "/api/orders/6" },
|
||||||
|
{ module: "orders", path: "/api/orders/600" },
|
||||||
|
{ module: "orders", path: "/api/orders/999" },
|
||||||
|
{ module: "orders", path: "/api/orders/abc" },
|
||||||
|
|
||||||
|
// ---------- Livraisons (Deliveries.tsx, BLReconciliation.tsx, Calendar.tsx, mobile) ----------
|
||||||
|
{ module: "deliveries", path: "/api/deliveries" },
|
||||||
|
{ module: "deliveries", path: "/api/deliveries?storeId=1" },
|
||||||
|
{ module: "deliveries", path: "/api/deliveries?storeId=2" },
|
||||||
|
{ module: "deliveries", path: "/api/deliveries?storeId=3" },
|
||||||
|
{ module: "deliveries", path: "/api/deliveries?storeId=abc" },
|
||||||
|
{ module: "deliveries", path: "/api/deliveries?" },
|
||||||
|
{ module: "deliveries", path: "/api/deliveries?status=delivered" },
|
||||||
|
{ module: "deliveries", path: "/api/deliveries?status=delivered&storeId=2" },
|
||||||
|
{ module: "deliveries", path: "/api/deliveries?status=inconnu" },
|
||||||
|
{ module: "deliveries", path: "/api/deliveries?startDate={{today-30}}&endDate={{today+30}}" },
|
||||||
|
{ module: "deliveries", path: "/api/deliveries?startDate={{today-30}}&endDate={{today+30}}&storeId=1" },
|
||||||
|
{ module: "deliveries", path: "/api/deliveries?storeId=2&startDate={{today}}&endDate={{today}}" },
|
||||||
|
{ module: "deliveries", path: "/api/deliveries?startDate={{today-30}}&endDate={{today+30}}&status=planned" },
|
||||||
|
{ module: "deliveries", path: "/api/deliveries?startDate=abc&endDate=xyz" },
|
||||||
|
{ module: "deliveries", path: "/api/deliveries?withBL=true" },
|
||||||
|
{ module: "deliveries", path: "/api/deliveries?withBL=true&storeId=1" },
|
||||||
|
{ module: "deliveries", path: "/api/deliveries/1" },
|
||||||
|
{ module: "deliveries", path: "/api/deliveries/3" },
|
||||||
|
{ module: "deliveries", path: "/api/deliveries/5" },
|
||||||
|
{ module: "deliveries", path: "/api/deliveries/6" },
|
||||||
|
{ module: "deliveries", path: "/api/deliveries/7" },
|
||||||
|
{ module: "deliveries", path: "/api/deliveries/600" },
|
||||||
|
{ module: "deliveries", path: "/api/deliveries/601" },
|
||||||
|
{ module: "deliveries", path: "/api/deliveries/999" },
|
||||||
|
{ module: "deliveries", path: "/api/deliveries/abc" },
|
||||||
|
|
||||||
|
// ---------- API externe (docs/API-RAPPROCHEMENT.md) ----------
|
||||||
|
ext("/stores"),
|
||||||
|
ext("/suppliers"),
|
||||||
|
ext("/deliveries"),
|
||||||
|
ext("/deliveries?storeId=1"),
|
||||||
|
ext("/deliveries?storeId=2&reconciled=true"),
|
||||||
|
ext("/deliveries?supplierId=2&hasInvoice=false"),
|
||||||
|
ext("/deliveries?blNumber=%20bl-1002%20"),
|
||||||
|
ext("/deliveries?blNumber=BL-1002&blNumber=BL-1004"),
|
||||||
|
ext("/deliveries?hasBl=true&from={{today-30}}&to={{today-2}}"),
|
||||||
|
ext("/deliveries?to={{today-6}}&reconciled=false"),
|
||||||
|
ext("/deliveries?limit=2&offset=1"),
|
||||||
|
ext("/deliveries?limit=1e3&offset=0x2"),
|
||||||
|
ext("/deliveries?offset=50&limit="),
|
||||||
|
ext("/deliveries?storeId=0&supplierId=1.5&reconciled=oui&hasBl=1&hasInvoice=&from=2026-1-1&to=x&limit=0&offset=-1"),
|
||||||
|
ext("/deliveries?storeId=1&storeId=2"),
|
||||||
|
ext("/deliveries?storeId=99999999999"),
|
||||||
|
ext("/deliveries/1"),
|
||||||
|
ext("/deliveries/2"),
|
||||||
|
ext("/deliveries/3"),
|
||||||
|
ext("/deliveries/602"),
|
||||||
|
ext("/deliveries/999"),
|
||||||
|
ext("/deliveries/abc"),
|
||||||
|
ext("/deliveries/0"),
|
||||||
|
ext("/deliveries/1.5"),
|
||||||
|
ext("/deliveries/99999999999"),
|
||||||
|
ext(""),
|
||||||
|
ext("/"),
|
||||||
|
ext("/inconnue"),
|
||||||
|
ext("/stores/extra"),
|
||||||
|
// Variantes qu'Express accepte (casse, barre finale) : relayées à Node
|
||||||
|
ext("/stores/"),
|
||||||
|
ext("/SUPPLIERS"),
|
||||||
|
|
||||||
|
// ---------- Relais ffnancy : sans session seulement (les autres appels partent sur Internet) ----------
|
||||||
|
{ module: "ffnancy", path: "/api/ffnancy/articles?ean=3245678901234&limit=1", accounts: ["anonyme"] },
|
||||||
|
{ module: "ffnancy", path: "/api/ffnancy/mouvements/entrees?artNoId=1&limit=1&dateDebut=2000-01-01", accounts: ["anonyme"] },
|
||||||
|
];
|
||||||
|
|
||||||
|
const NEW_DELIVERY = { supplierId: 1, groupId: 1, scheduledDate: "{{today+4}}", quantity: 1, unit: "colis" };
|
||||||
|
|
||||||
|
export const writes: WriteScenario[] = [
|
||||||
|
// ---------- Commandes ----------
|
||||||
|
{
|
||||||
|
module: "orders",
|
||||||
|
name: "commandes : créer, modifier, supprimer",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/orders", body: { supplierId: 1, groupId: 1, plannedDate: "{{today+7}}", notes: "Commande du banc" } },
|
||||||
|
{ method: "PUT", path: "/api/orders/{{0.id}}", body: { plannedDate: "{{today+8}}", notes: "Modifiée", quantity: 3, unit: "palettes" } },
|
||||||
|
{ method: "GET", path: "/api/orders/{{0.id}}" },
|
||||||
|
{ method: "DELETE", path: "/api/orders/{{0.id}}" },
|
||||||
|
{ method: "POST", path: "/api/orders", body: { supplierId: 2, groupId: 2, plannedDate: "{{today+1}}" } },
|
||||||
|
{ method: "PUT", path: "/api/orders/4", body: { status: "planned" } },
|
||||||
|
{ method: "PUT", path: "/api/orders/6", body: { notes: "Épinal" } },
|
||||||
|
{ method: "DELETE", path: "/api/orders/6" },
|
||||||
|
{ method: "DELETE", path: "/api/orders/3" },
|
||||||
|
// Commande qui a des livraisons : clé étrangère
|
||||||
|
{ method: "DELETE", path: "/api/orders/2" },
|
||||||
|
{ method: "GET", path: "/api/orders?storeId=1" },
|
||||||
|
],
|
||||||
|
tables: ["orders"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "orders",
|
||||||
|
name: "commandes : corps invalides et identifiants inconnus",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/orders", body: {} },
|
||||||
|
{ method: "POST", path: "/api/orders", body: { supplierId: "1", groupId: 1.5, plannedDate: 5, quantity: "2" } },
|
||||||
|
{ method: "POST", path: "/api/orders", body: [] },
|
||||||
|
{ method: "POST", path: "/api/orders", body: { supplierId: 1, groupId: 1, plannedDate: "pas une date" } },
|
||||||
|
{ method: "POST", path: "/api/orders", body: { supplierId: 999, groupId: 1, plannedDate: "{{today}}" } },
|
||||||
|
{ method: "POST", path: "/api/orders", body: { supplierId: 1, groupId: 1, plannedDate: "{{today}}", createdBy: "admin_local", status: "delivered", id: 999, createdAt: "{{today}}" } },
|
||||||
|
{ method: "PUT", path: "/api/orders/1", body: { groupId: "x" } },
|
||||||
|
{ method: "PUT", path: "/api/orders/1", body: [] },
|
||||||
|
{ method: "PUT", path: "/api/orders/1", body: {} },
|
||||||
|
// Commande déplacée dans un autre magasin, créateur changé
|
||||||
|
{ method: "PUT", path: "/api/orders/3", body: { groupId: 3, createdBy: "bench_employee" } },
|
||||||
|
{ method: "PUT", path: "/api/orders/999", body: {} },
|
||||||
|
{ method: "PUT", path: "/api/orders/abc", body: {} },
|
||||||
|
{ method: "DELETE", path: "/api/orders/999" },
|
||||||
|
{ method: "DELETE", path: "/api/orders/abc" },
|
||||||
|
{ method: "GET", path: "/api/orders/3" },
|
||||||
|
],
|
||||||
|
tables: ["orders"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "orders",
|
||||||
|
name: "synchroniser les statuts commandes / livraisons",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/sync-order-delivery-status" },
|
||||||
|
{ method: "POST", path: "/api/sync-order-delivery-status" },
|
||||||
|
{ method: "GET", path: "/api/orders/600" },
|
||||||
|
],
|
||||||
|
tables: ["orders"],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------- Livraisons ----------
|
||||||
|
{
|
||||||
|
module: "deliveries",
|
||||||
|
name: "livraisons : créer, modifier, supprimer",
|
||||||
|
steps: [
|
||||||
|
// CreateDeliveryModal : commande 3 en attente, passée à « planned »
|
||||||
|
{ method: "POST", path: "/api/deliveries", body: { orderId: "3", supplierId: 3, groupId: 1, scheduledDate: "{{today+3}}", quantity: 2, unit: "palettes", status: "planned", notes: "Livraison du banc", createdBy: "admin_local" } },
|
||||||
|
// EditDeliveryModal
|
||||||
|
{ method: "PUT", path: "/api/deliveries/{{0.id}}", body: { supplierId: 3, groupId: 1, orderId: null, scheduledDate: "{{today+5}}", quantity: 4, unit: "palettes", notes: "Modifiée" } },
|
||||||
|
{ method: "GET", path: "/api/deliveries/{{0.id}}" },
|
||||||
|
{ method: "DELETE", path: "/api/deliveries/{{0.id}}" },
|
||||||
|
{ method: "POST", path: "/api/deliveries", body: { ...NEW_DELIVERY, orderId: "none", groupId: 2 } },
|
||||||
|
{ method: "POST", path: "/api/deliveries", body: { ...NEW_DELIVERY, orderId: 4 } },
|
||||||
|
{ method: "POST", path: "/api/deliveries", body: { ...NEW_DELIVERY, orderId: 999 } },
|
||||||
|
{ method: "PUT", path: "/api/deliveries/5", body: { orderId: 1 } },
|
||||||
|
{ method: "PUT", path: "/api/deliveries/5", body: { orderId: 999 } },
|
||||||
|
{ method: "PUT", path: "/api/deliveries/5", body: { notes: "Metz" } },
|
||||||
|
{ method: "PUT", path: "/api/deliveries/601", body: { orderId: 3 } },
|
||||||
|
{ method: "DELETE", path: "/api/deliveries/7" },
|
||||||
|
// Commentaires supprimés avec la livraison (cascade)
|
||||||
|
{ method: "DELETE", path: "/api/deliveries/2" },
|
||||||
|
{ method: "GET", path: "/api/deliveries?storeId=1" },
|
||||||
|
],
|
||||||
|
tables: ["deliveries", "orders", "reconciliation_comments"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "deliveries",
|
||||||
|
name: "livraisons : corps invalides et identifiants inconnus",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/deliveries", body: {} },
|
||||||
|
{ method: "POST", path: "/api/deliveries", body: [] },
|
||||||
|
{ method: "POST", path: "/api/deliveries", body: { ...NEW_DELIVERY, orderId: "abc" } },
|
||||||
|
{ method: "POST", path: "/api/deliveries", body: { ...NEW_DELIVERY, orderId: "12abc" } },
|
||||||
|
{ method: "POST", path: "/api/deliveries", body: { ...NEW_DELIVERY, orderId: 0 } },
|
||||||
|
{ method: "POST", path: "/api/deliveries", body: { ...NEW_DELIVERY, orderId: "" } },
|
||||||
|
{ method: "POST", path: "/api/deliveries", body: { ...NEW_DELIVERY, deliveredDate: "{{today}}" } },
|
||||||
|
{ method: "POST", path: "/api/deliveries", body: { ...NEW_DELIVERY, status: "", blAmount: "12.5", blNumber: "BL-9000" } },
|
||||||
|
{ method: "POST", path: "/api/deliveries", body: { ...NEW_DELIVERY, groupId: 3, orderId: 6 } },
|
||||||
|
{ method: "PUT", path: "/api/deliveries/1", body: { invoiceAmountTTC: 5 } },
|
||||||
|
{ method: "PUT", path: "/api/deliveries/1", body: { dueDate: "pas une date" } },
|
||||||
|
{ method: "PUT", path: "/api/deliveries/4", body: { deliveredDate: "{{today}}" } },
|
||||||
|
{ method: "PUT", path: "/api/deliveries/4", body: { validatedAt: "", blAmount: 99.5, invoiceAmount: true, notes: null } },
|
||||||
|
{ method: "PUT", path: "/api/deliveries/4", body: { blAmount: 99.5, notes: null, blNumber: "" } },
|
||||||
|
{ method: "PUT", path: "/api/deliveries/4", body: [] },
|
||||||
|
{ method: "PUT", path: "/api/deliveries/999", body: {} },
|
||||||
|
{ method: "PUT", path: "/api/deliveries/abc", body: {} },
|
||||||
|
{ method: "DELETE", path: "/api/deliveries/999" },
|
||||||
|
{ method: "DELETE", path: "/api/deliveries/abc" },
|
||||||
|
{ method: "GET", path: "/api/deliveries/4" },
|
||||||
|
],
|
||||||
|
tables: ["deliveries", "orders"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "deliveries",
|
||||||
|
name: "livraisons : référence de facture et échéance (NocoDB)",
|
||||||
|
steps: [
|
||||||
|
{ method: "PUT", path: "/api/deliveries/2", body: { invoiceReference: "FAC-2002", invoiceAmount: "310.00" } },
|
||||||
|
{ method: "PUT", path: "/api/deliveries/2", body: { invoiceReference: "FAC-2002" } },
|
||||||
|
{ method: "PUT", path: "/api/deliveries/2", body: { invoiceReference: "FAC-9999" } },
|
||||||
|
{ method: "PUT", path: "/api/deliveries/2", body: { invoiceReference: "", dueDate: "{{today+60}}" } },
|
||||||
|
{ method: "PUT", path: "/api/deliveries/4", body: { invoiceReference: "AV-3001" } },
|
||||||
|
{ method: "PUT", path: "/api/deliveries/6", body: { invoiceReference: "FAC-2006" } },
|
||||||
|
{ method: "PUT", path: "/api/deliveries/6", body: { invoiceReference: "FAC-2001", dueDate: null } },
|
||||||
|
// Validation rapide et dévalidation (BLReconciliation.tsx)
|
||||||
|
{ method: "PUT", path: "/api/deliveries/4", body: { reconciled: true, validatedAt: "{{today}}T08:00:00.000Z" } },
|
||||||
|
{ method: "PUT", path: "/api/deliveries/1", body: { reconciled: false, validatedAt: null } },
|
||||||
|
{ method: "GET", path: "/api/deliveries/2" },
|
||||||
|
],
|
||||||
|
tables: ["deliveries", "invoice_verification_cache"],
|
||||||
|
ignore: CACHE_IGNORE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "deliveries",
|
||||||
|
name: "livraisons : rapprochement automatique à la modification",
|
||||||
|
steps: [
|
||||||
|
// Bonduelle : livrée avec un BL, rapprochée d'office (la réponse reste la première mise à jour)
|
||||||
|
{ method: "PUT", path: "/api/deliveries/601", body: { status: "delivered", blNumber: "BL-6010" } },
|
||||||
|
{ method: "PUT", path: "/api/deliveries/3", body: { blNumber: "BL-3000" } },
|
||||||
|
// Fournisseur changé : relu dans la table
|
||||||
|
{ method: "PUT", path: "/api/deliveries/3", body: { supplierId: 1, status: "delivered" } },
|
||||||
|
{ method: "PUT", path: "/api/deliveries/600", body: { supplierId: 2, blNumber: "BL-6000" } },
|
||||||
|
{ method: "GET", path: "/api/deliveries/601" },
|
||||||
|
],
|
||||||
|
tables: ["deliveries"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "deliveries",
|
||||||
|
name: "livraisons : valider",
|
||||||
|
steps: [
|
||||||
|
// Bonduelle, commande 2 : livraison rapprochée d'office, commande livrée
|
||||||
|
{ method: "POST", path: "/api/deliveries/3/validate", body: { blNumber: " BL-3001 ", blAmount: 150.5 } },
|
||||||
|
{ method: "POST", path: "/api/deliveries/5/validate", body: { blNumber: "BL-5001" } },
|
||||||
|
{ method: "POST", path: "/api/deliveries/7/validate", body: { blNumber: "BL-7001", blAmount: "" } },
|
||||||
|
// Cache : référence et BL de la livraison marqués « rapprochés »
|
||||||
|
{ method: "POST", path: "/api/deliveries/4/validate", body: { blNumber: "BL-1004" } },
|
||||||
|
{ method: "POST", path: "/api/deliveries/8/validate", body: { blNumber: "BL-0900", blAmount: null } },
|
||||||
|
{ method: "POST", path: "/api/deliveries/2/validate", body: { blNumber: " " } },
|
||||||
|
{ method: "POST", path: "/api/deliveries/2/validate", body: {} },
|
||||||
|
{ method: "POST", path: "/api/deliveries/2/validate", body: [] },
|
||||||
|
{ method: "POST", path: "/api/deliveries/2/validate", body: { blNumber: 5 } },
|
||||||
|
{ method: "POST", path: "/api/deliveries/2/validate", body: { blNumber: "BL-1002", blAmount: "abc" } },
|
||||||
|
{ method: "POST", path: "/api/deliveries/999/validate", body: { blNumber: "x" } },
|
||||||
|
{ method: "POST", path: "/api/deliveries/abc/validate", body: { blNumber: "x" } },
|
||||||
|
{ method: "GET", path: "/api/deliveries/3" },
|
||||||
|
],
|
||||||
|
tables: ["deliveries", "orders", "invoice_verification_cache"],
|
||||||
|
ignore: CACHE_IGNORE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "deliveries",
|
||||||
|
name: "livraisons : contrôle effectué",
|
||||||
|
steps: [
|
||||||
|
{ method: "PUT", path: "/api/deliveries/4/control" },
|
||||||
|
{ method: "PUT", path: "/api/deliveries/6/control" },
|
||||||
|
{ method: "PUT", path: "/api/deliveries/999/control" },
|
||||||
|
{ method: "PUT", path: "/api/deliveries/abc/control" },
|
||||||
|
{ method: "PUT", path: "/api/deliveries/99999999999/control" },
|
||||||
|
{ method: "GET", path: "/api/deliveries?storeId=2" },
|
||||||
|
],
|
||||||
|
tables: ["deliveries"],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------- API externe ----------
|
||||||
|
{
|
||||||
|
module: "external-api-v1",
|
||||||
|
name: "API externe : écrire la facture",
|
||||||
|
accounts: ["api"],
|
||||||
|
steps: [
|
||||||
|
// Bonduelle (automatique), non rapprochée : échéance reprise de NocoDB
|
||||||
|
{ method: "PATCH", path: "/api/ext/v1/deliveries/2", body: { invoiceReference: " FAC-2002 ", invoiceAmount: "1 234,56", invoiceAmountTTC: 1481.47 } },
|
||||||
|
{ method: "PATCH", path: "/api/ext/v1/deliveries/2", body: { invoiceReference: "FAC-2002" } },
|
||||||
|
{ method: "PATCH", path: "/api/ext/v1/deliveries/2", body: { dueDate: "{{today+45}}", reconciled: true } },
|
||||||
|
{ method: "PATCH", path: "/api/ext/v1/deliveries/2", body: { invoiceAmount: "10" } },
|
||||||
|
// Lactalis rapprochée : figée, sauf dévalidation dans la même requête
|
||||||
|
{ method: "PATCH", path: "/api/ext/v1/deliveries/1", body: { invoiceAmount: 5 } },
|
||||||
|
{ method: "PATCH", path: "/api/ext/v1/deliveries/1", body: { invoiceAmount: 5, reconciled: false } },
|
||||||
|
{ method: "PATCH", path: "/api/ext/v1/deliveries/1", body: { invoiceReference: "" } },
|
||||||
|
{ method: "PATCH", path: "/api/ext/v1/deliveries/1", body: { reconciled: true } },
|
||||||
|
{ method: "PATCH", path: "/api/ext/v1/deliveries/602", body: { invoiceReference: "FAC-2006", invoiceAmountTTC: null } },
|
||||||
|
{ method: "PATCH", path: "/api/ext/v1/deliveries/3", body: { invoiceReference: "X" } },
|
||||||
|
{ method: "PATCH", path: "/api/ext/v1/deliveries/4", body: { invoiceReference: "FAC-9999" } },
|
||||||
|
{ method: "PATCH", path: "/api/ext/v1/deliveries/4", body: { dueDate: "2026-02-30" } },
|
||||||
|
{ method: "PATCH", path: "/api/ext/v1/deliveries/4", body: { dueDate: "2026-13-45" } },
|
||||||
|
{ method: "PATCH", path: "/api/ext/v1/deliveries/999", body: { reconciled: true } },
|
||||||
|
{ method: "PATCH", path: "/api/ext/v1/deliveries/abc", body: { reconciled: true } },
|
||||||
|
{ method: "PATCH", path: "/api/ext/v1/deliveries/99999999999", body: { reconciled: true } },
|
||||||
|
{ method: "GET", path: "/api/ext/v1/deliveries?storeId=1&hasInvoice=true" },
|
||||||
|
],
|
||||||
|
tables: ["deliveries", "invoice_verification_cache"],
|
||||||
|
ignore: CACHE_IGNORE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "external-api-v1",
|
||||||
|
name: "API externe : corps invalides et routes inconnues",
|
||||||
|
accounts: ["api"],
|
||||||
|
steps: [
|
||||||
|
{ method: "PATCH", path: "/api/ext/v1/deliveries/2", body: {} },
|
||||||
|
{ method: "PATCH", path: "/api/ext/v1/deliveries/2", body: [] },
|
||||||
|
{ method: "PATCH", path: "/api/ext/v1/deliveries/2", body: { foo: 1, 2: 3 } },
|
||||||
|
{ method: "PATCH", path: "/api/ext/v1/deliveries/2", body: { invoiceAmount: "abc", dueDate: "15/10/2026", reconciled: "oui", extra: 1 } },
|
||||||
|
{ method: "PATCH", path: "/api/ext/v1/deliveries/2", body: { invoiceAmount: true, invoiceAmountTTC: "1e9" } },
|
||||||
|
{ method: "PATCH", path: "/api/ext/v1/deliveries/2", body: { invoiceReference: "x".repeat(101) } },
|
||||||
|
{ method: "PATCH", path: "/api/ext/v1/deliveries/2", body: { invoiceReference: 5, reconciled: null } },
|
||||||
|
{ method: "PATCH", path: "/api/ext/v1/deliveries/abc", body: {} },
|
||||||
|
{ method: "POST", path: "/api/ext/v1/deliveries", body: {} },
|
||||||
|
{ method: "PUT", path: "/api/ext/v1/deliveries/2", body: {} },
|
||||||
|
{ method: "DELETE", path: "/api/ext/v1/stores" },
|
||||||
|
{ method: "POST", path: "/api/ext/v1/inconnue", body: { a: 1 } },
|
||||||
|
{ method: "GET", path: "/api/ext/v1/deliveries/2" },
|
||||||
|
],
|
||||||
|
tables: ["deliveries"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "external-api-v1",
|
||||||
|
name: "API externe : clés refusées ou passées autrement",
|
||||||
|
accounts: ["api"],
|
||||||
|
steps: [
|
||||||
|
{ method: "GET", path: "/api/ext/v1/stores", headers: { "x-api-key": "lf_cle_revoquee" } },
|
||||||
|
{ method: "GET", path: "/api/ext/v1/stores", headers: { "x-api-key": "mauvaise" } },
|
||||||
|
{ method: "GET", path: "/api/ext/v1/stores", headers: { "x-api-key": " ", authorization: `Bearer ${BENCH_API_KEY}` } },
|
||||||
|
{ method: "GET", path: "/api/ext/v1/suppliers", headers: { "x-api-key": "", authorization: `bearer ${BENCH_API_KEY} ` } },
|
||||||
|
{ method: "GET", path: "/api/ext/v1/stores", headers: { "x-api-key": " ", authorization: "Bearer " } },
|
||||||
|
{ method: "GET", path: "/api/ext/v1/stores", headers: { "x-api-key": " ", authorization: `Basic ${BENCH_API_KEY}` } },
|
||||||
|
{ method: "PATCH", path: "/api/ext/v1/deliveries/2", body: { reconciled: true }, headers: { "x-api-key": "mauvaise" } },
|
||||||
|
{ method: "POST", path: "/api/ext/v1/inconnue", headers: { "x-api-key": "mauvaise" } },
|
||||||
|
],
|
||||||
|
tables: ["deliveries"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "external-api-v1",
|
||||||
|
name: "API externe : désactivée sans clé active",
|
||||||
|
accounts: ["admin"],
|
||||||
|
steps: [
|
||||||
|
// La session ne donne pas accès à l'API externe
|
||||||
|
{ method: "GET", path: "/api/ext/v1/stores" },
|
||||||
|
{ method: "GET", path: "/api/ext/v1/stores", headers: { "x-api-key": BENCH_API_KEY } },
|
||||||
|
{ method: "DELETE", path: "/api/external-api/keys/1" },
|
||||||
|
{ method: "GET", path: "/api/ext/v1/stores" },
|
||||||
|
{ method: "GET", path: "/api/ext/v1/stores", headers: { "x-api-key": BENCH_API_KEY } },
|
||||||
|
{ method: "GET", path: "/api/ext/v1/inconnue" },
|
||||||
|
{ method: "PATCH", path: "/api/ext/v1/deliveries/2", body: { reconciled: true }, headers: { "x-api-key": BENCH_API_KEY } },
|
||||||
|
],
|
||||||
|
tables: ["external_api_keys"],
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,410 @@
|
|||||||
|
// Lot 4, rapprochement : avoirs (et leur webhook), échéancier des paiements (lecture
|
||||||
|
// complétée par NocoDB, export CSV), envoi d'une facture au webhook du magasin,
|
||||||
|
// SQL et migration d'urgence de l'administration. NocoDB et les webhooks sont
|
||||||
|
// simulés par stub.ts. Données complémentaires : seeds/lot4-rapprochement.ts.
|
||||||
|
import { STUB_URL } from "../config";
|
||||||
|
import type { ReadCase, WriteScenario } from "../types";
|
||||||
|
|
||||||
|
// Mois « AAAA-MM » relatif au mois courant (sélecteur de l'échéancier)
|
||||||
|
function month(offset: number): string {
|
||||||
|
const date = new Date();
|
||||||
|
date.setUTCDate(1);
|
||||||
|
date.setUTCMonth(date.getUTCMonth() + offset);
|
||||||
|
return date.toISOString().slice(0, 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Durée de vie des entrées du cache : relative à l'heure du passage, différente
|
||||||
|
// entre les deux serveurs
|
||||||
|
const CACHE_IGNORE = ["expires_at"];
|
||||||
|
|
||||||
|
const ALL_METHODS = ["virement", "prelevement", "cheque", "Non défini"];
|
||||||
|
|
||||||
|
// Fichier envoyé par BLReconciliation.tsx avec les champs du formulaire
|
||||||
|
const INVOICE_FILE = [{ field: "file", filename: "facture BL-1001.pdf", contentType: "application/pdf", size: 2048 }];
|
||||||
|
|
||||||
|
export const reads: ReadCase[] = [
|
||||||
|
// ---------- Avoirs (Avoirs.tsx, mobile/AvoirsPage.tsx : storeId du magasin choisi) ----------
|
||||||
|
{ module: "avoirs", path: "/api/avoirs" },
|
||||||
|
{ module: "avoirs", path: "/api/avoirs?storeId=1" },
|
||||||
|
{ module: "avoirs", path: "/api/avoirs?storeId=2" },
|
||||||
|
{ module: "avoirs", path: "/api/avoirs?storeId=3" },
|
||||||
|
{ module: "avoirs", path: "/api/avoirs?storeId=" },
|
||||||
|
{ module: "avoirs", path: "/api/avoirs?storeId=abc" },
|
||||||
|
{ module: "avoirs", path: "/api/avoirs?storeId=99999999999" },
|
||||||
|
{ module: "avoirs", path: "/api/avoirs/1" },
|
||||||
|
{ module: "avoirs", path: "/api/avoirs/3" },
|
||||||
|
{ module: "avoirs", path: "/api/avoirs/701" },
|
||||||
|
{ module: "avoirs", path: "/api/avoirs/999" },
|
||||||
|
{ module: "avoirs", path: "/api/avoirs/abc" },
|
||||||
|
|
||||||
|
// ---------- Échéancier (PaymentSchedulePage.tsx : groupId du magasin choisi) ----------
|
||||||
|
// La lecture complète en base les échéances et montants manquants (NocoDB) :
|
||||||
|
// les comptes suivants voient les livraisons déjà complétées
|
||||||
|
{ module: "payment-schedule", path: "/api/payment-schedule?groupId=1" },
|
||||||
|
{ module: "payment-schedule", path: "/api/payment-schedule?groupId=2" },
|
||||||
|
{ module: "payment-schedule", path: "/api/payment-schedule?groupId=3" },
|
||||||
|
{ module: "payment-schedule", path: "/api/payment-schedule?groupId=999" },
|
||||||
|
{ module: "payment-schedule", path: "/api/payment-schedule?groupId=abc", accounts: ["admin", "directeur", "manager"] },
|
||||||
|
{ module: "payment-schedule", path: "/api/payment-schedule", accounts: ["admin", "directeur"] },
|
||||||
|
{ module: "payment-schedule", path: "/api/payment-schedule?groupId=", accounts: ["admin", "directeur"] },
|
||||||
|
{ module: "payment-schedule", path: "/api/payment-schedule?groupId=0", accounts: ["admin", "directeur"] },
|
||||||
|
{ module: "payment-schedule", path: "/api/payment-schedule?groupId=1.5", accounts: ["admin", "directeur"] },
|
||||||
|
{ module: "payment-schedule", path: "/api/payment-schedule?groupId=-1", accounts: ["admin", "directeur"] },
|
||||||
|
{ module: "payment-schedule", path: "/api/payment-schedule?groupId=%201%20", accounts: ["admin", "directeur"] },
|
||||||
|
{ module: "payment-schedule", path: "/api/payment-schedule?groupId=99999999999", accounts: ["admin", "directeur"] },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const writes: WriteScenario[] = [
|
||||||
|
// ---------- Avoirs ----------
|
||||||
|
{
|
||||||
|
module: "avoirs",
|
||||||
|
name: "créer un avoir (webhook du magasin à la création)",
|
||||||
|
steps: [
|
||||||
|
// Formulaire du bureau
|
||||||
|
{ method: "POST", path: "/api/avoirs", body: { supplierId: 1, groupId: 1, invoiceReference: "AV-4001", amount: 12.5, comment: "Casse", commercialProcessed: false, status: "En attente de demande" } },
|
||||||
|
{ method: "GET", path: "/api/avoirs/{{0.id}}" },
|
||||||
|
// Formulaire mobile : montant forcé à 0, sans référence ni commentaire
|
||||||
|
{ method: "POST", path: "/api/avoirs", body: { supplierId: 2, groupId: 1, amount: 0, status: "Demandé", createdBy: "pirate" } },
|
||||||
|
// Metz : sans webhook
|
||||||
|
{ method: "POST", path: "/api/avoirs", body: { supplierId: 3, groupId: 2, invoiceReference: "", amount: "-7.25", comment: "", commercialProcessed: true, status: "Reçu" } },
|
||||||
|
// Épinal : aucun compte du banc n'y est affecté (sauf l'administrateur)
|
||||||
|
{ method: "POST", path: "/api/avoirs", body: { supplierId: 4, groupId: 3, amount: null } },
|
||||||
|
{ method: "GET", path: "/api/avoirs" },
|
||||||
|
],
|
||||||
|
tables: ["avoirs"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Fiche du magasin modifiable par l'admin et le manager
|
||||||
|
module: "avoirs",
|
||||||
|
name: "webhook du magasin en erreur, injoignable ou invalide",
|
||||||
|
accounts: ["admin", "manager"],
|
||||||
|
steps: [
|
||||||
|
{ method: "PUT", path: "/api/groups/1", body: { webhookUrl: `${STUB_URL}/inconnu` } },
|
||||||
|
{ method: "POST", path: "/api/avoirs", body: { supplierId: 1, groupId: 1, invoiceReference: "AV-4101", amount: 1 } },
|
||||||
|
{ method: "PUT", path: "/api/groups/1", body: { webhookUrl: "http://127.0.0.1:1/webhook" } },
|
||||||
|
{ method: "POST", path: "/api/avoirs", body: { supplierId: 1, groupId: 1, invoiceReference: "AV-4102", amount: 2 } },
|
||||||
|
{ method: "PUT", path: "/api/avoirs/1", body: { status: "Reçu" } },
|
||||||
|
{ method: "PUT", path: "/api/groups/1", body: { webhookUrl: "pas une adresse" } },
|
||||||
|
{ method: "POST", path: "/api/avoirs", body: { supplierId: 1, groupId: 1, invoiceReference: "AV-4103", amount: 3 } },
|
||||||
|
],
|
||||||
|
tables: ["avoirs"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "avoirs",
|
||||||
|
name: "avoirs invalides à la création",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/avoirs", body: {} },
|
||||||
|
{ method: "POST", path: "/api/avoirs", body: { supplierId: "1", groupId: 1.5 } },
|
||||||
|
{ method: "POST", path: "/api/avoirs", body: { supplierId: 1, groupId: 1, amount: "abc", status: "Perdu", commercialProcessed: "oui" } },
|
||||||
|
{ method: "POST", path: "/api/avoirs", body: { supplierId: 1, groupId: 1, invoiceReference: null, comment: null, commercialProcessed: null } },
|
||||||
|
{ method: "POST", path: "/api/avoirs", body: ["x"] },
|
||||||
|
// Erreurs de la base : fournisseur ou magasin inconnu, référence trop longue, montant trop grand
|
||||||
|
{ method: "POST", path: "/api/avoirs", body: { supplierId: 999, groupId: 1 } },
|
||||||
|
{ method: "POST", path: "/api/avoirs", body: { supplierId: 1, groupId: 999 } },
|
||||||
|
{ method: "POST", path: "/api/avoirs", body: { supplierId: 1, groupId: 1, invoiceReference: "R".repeat(300) } },
|
||||||
|
{ method: "POST", path: "/api/avoirs", body: { supplierId: 1, groupId: 1, amount: 1e9 } },
|
||||||
|
// Montant vide : Number("") = 0
|
||||||
|
{ method: "POST", path: "/api/avoirs", body: { supplierId: 1, groupId: 2, amount: "" } },
|
||||||
|
],
|
||||||
|
tables: ["avoirs"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "avoirs",
|
||||||
|
name: "modifier un avoir (passage à « Reçu » et webhook)",
|
||||||
|
steps: [
|
||||||
|
// Formulaire complet : passage à « Reçu », webhook du magasin
|
||||||
|
{ method: "PUT", path: "/api/avoirs/1", body: { supplierId: 1, groupId: 1, invoiceReference: "AV-3001", amount: 50, comment: "Reçu par courrier", commercialProcessed: true, status: "Reçu" } },
|
||||||
|
// Déjà « Reçu » : pas de webhook ; montant, référence et commentaire effacés (AVOIRS-API-06)
|
||||||
|
{ method: "PUT", path: "/api/avoirs/1", body: { status: "Reçu" } },
|
||||||
|
// Metz (sans webhook)
|
||||||
|
{ method: "PUT", path: "/api/avoirs/3", body: { status: "Demandé", amount: "15.5", invoiceReference: "AV-3003", comment: "" } },
|
||||||
|
{ method: "PUT", path: "/api/avoirs/700", body: { status: "Reçu", invoiceReference: "FAC-2006", amount: 10.5 } },
|
||||||
|
// Changement de magasin et de créateur acceptés (AVOIRS-API-07)
|
||||||
|
{ method: "PUT", path: "/api/avoirs/2", body: { groupId: 2, createdBy: "pirate", invoiceReference: "AV-3002" } },
|
||||||
|
// Épinal
|
||||||
|
{ method: "PUT", path: "/api/avoirs/701", body: { status: "Reçu" } },
|
||||||
|
{ method: "GET", path: "/api/avoirs/1" },
|
||||||
|
],
|
||||||
|
tables: ["avoirs"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "avoirs",
|
||||||
|
name: "avoirs invalides ou introuvables à la modification",
|
||||||
|
steps: [
|
||||||
|
{ method: "PUT", path: "/api/avoirs/1", body: { status: "Perdu" } },
|
||||||
|
{ method: "PUT", path: "/api/avoirs/1", body: { amount: "abc", supplierId: "2" } },
|
||||||
|
{ method: "PUT", path: "/api/avoirs/1", body: { invoiceReference: null } },
|
||||||
|
{ method: "PUT", path: "/api/avoirs/1", body: { supplierId: 999 } },
|
||||||
|
{ method: "PUT", path: "/api/avoirs/999", body: { status: "Reçu" } },
|
||||||
|
{ method: "PUT", path: "/api/avoirs/abc", body: { status: "Reçu" } },
|
||||||
|
{ method: "PUT", path: "/api/avoirs/1", body: [] },
|
||||||
|
],
|
||||||
|
tables: ["avoirs"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "avoirs",
|
||||||
|
name: "supprimer des avoirs",
|
||||||
|
steps: [
|
||||||
|
{ method: "DELETE", path: "/api/avoirs/2" },
|
||||||
|
{ method: "DELETE", path: "/api/avoirs/700" },
|
||||||
|
{ method: "DELETE", path: "/api/avoirs/701" },
|
||||||
|
{ method: "DELETE", path: "/api/avoirs/999" },
|
||||||
|
{ method: "DELETE", path: "/api/avoirs/abc" },
|
||||||
|
{ method: "GET", path: "/api/avoirs" },
|
||||||
|
],
|
||||||
|
tables: ["avoirs"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "avoirs",
|
||||||
|
name: "vérifier la facture d'un avoir dans NocoDB",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/avoirs/1/verify-invoice", body: { invoiceReference: "AV-3001", forceRefresh: false } },
|
||||||
|
// Depuis le cache, puis en forçant
|
||||||
|
{ method: "POST", path: "/api/avoirs/1/verify-invoice", body: { invoiceReference: "AV-3001" } },
|
||||||
|
{ method: "POST", path: "/api/avoirs/1/verify-invoice", body: { invoiceReference: " av-3001 ", forceRefresh: "oui" } },
|
||||||
|
{ method: "POST", path: "/api/avoirs/2/verify-invoice", body: { invoiceReference: "AV-INCONNU" } },
|
||||||
|
{ method: "POST", path: "/api/avoirs/700/verify-invoice", body: { invoiceReference: "FAC-2006" } },
|
||||||
|
// Épinal : sans configuration NocoDB
|
||||||
|
{ method: "POST", path: "/api/avoirs/701/verify-invoice", body: { invoiceReference: "FAC-2004" } },
|
||||||
|
{ method: "POST", path: "/api/avoirs/1/verify-invoice", body: {} },
|
||||||
|
{ method: "POST", path: "/api/avoirs/1/verify-invoice", body: { invoiceReference: " " } },
|
||||||
|
{ method: "POST", path: "/api/avoirs/1/verify-invoice", body: { invoiceReference: 2001 } },
|
||||||
|
{ method: "POST", path: "/api/avoirs/999/verify-invoice", body: { invoiceReference: "AV-3001" } },
|
||||||
|
{ method: "POST", path: "/api/avoirs/abc/verify-invoice", body: { invoiceReference: "AV-3001" } },
|
||||||
|
],
|
||||||
|
tables: ["invoice_verification_cache"],
|
||||||
|
ignore: CACHE_IGNORE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "avoirs",
|
||||||
|
name: "valider un avoir et l'état de son webhook",
|
||||||
|
steps: [
|
||||||
|
// Entrée du cache à marquer « rapprochée » à la validation
|
||||||
|
{ method: "POST", path: "/api/avoirs/1/verify-invoice", body: { invoiceReference: "AV-3001" } },
|
||||||
|
{ method: "PUT", path: "/api/avoirs/1/nocodb-verification", body: { verified: true } },
|
||||||
|
{ method: "PUT", path: "/api/avoirs/2/nocodb-verification", body: { verified: false } },
|
||||||
|
// Référence absente ou blanche : rien à marquer ; autre magasin accepté (AVOIRS-API-08)
|
||||||
|
{ method: "PUT", path: "/api/avoirs/3/nocodb-verification", body: { verified: true } },
|
||||||
|
{ method: "PUT", path: "/api/avoirs/702/nocodb-verification", body: { verified: "yes" } },
|
||||||
|
{ method: "PUT", path: "/api/avoirs/700/nocodb-verification", body: {} },
|
||||||
|
{ method: "PUT", path: "/api/avoirs/701/nocodb-verification", body: { verified: "oui" } },
|
||||||
|
{ method: "PUT", path: "/api/avoirs/999/nocodb-verification", body: { verified: true } },
|
||||||
|
{ method: "PUT", path: "/api/avoirs/abc/nocodb-verification", body: { verified: true } },
|
||||||
|
{ method: "PUT", path: "/api/avoirs/1/webhook-status", body: { webhookSent: false } },
|
||||||
|
{ method: "PUT", path: "/api/avoirs/2/webhook-status", body: { webhookSent: 1 } },
|
||||||
|
{ method: "PUT", path: "/api/avoirs/700/webhook-status", body: {} },
|
||||||
|
{ method: "PUT", path: "/api/avoirs/701/webhook-status", body: { webhookSent: null } },
|
||||||
|
{ method: "PUT", path: "/api/avoirs/3/webhook-status", body: { webhookSent: "oui" } },
|
||||||
|
{ method: "PUT", path: "/api/avoirs/999/webhook-status", body: { webhookSent: true } },
|
||||||
|
{ method: "PUT", path: "/api/avoirs/abc/webhook-status", body: { webhookSent: true } },
|
||||||
|
],
|
||||||
|
tables: ["avoirs", "invoice_verification_cache"],
|
||||||
|
ignore: CACHE_IGNORE,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------- Échéancier ----------
|
||||||
|
{
|
||||||
|
module: "payment-schedule",
|
||||||
|
name: "échéancier : échéances et montants complétés par NocoDB",
|
||||||
|
steps: [
|
||||||
|
{ method: "GET", path: "/api/payment-schedule?groupId=1" },
|
||||||
|
// Seconde lecture : le repli « TTC » recommence (montant TTC jamais écrit)
|
||||||
|
{ method: "GET", path: "/api/payment-schedule?groupId=1" },
|
||||||
|
{ method: "GET", path: "/api/payment-schedule?groupId=2" },
|
||||||
|
{ method: "GET", path: "/api/payment-schedule?groupId=3" },
|
||||||
|
],
|
||||||
|
tables: ["deliveries", "invoice_verification_cache"],
|
||||||
|
ignore: CACHE_IGNORE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "payment-schedule",
|
||||||
|
name: "export CSV de l'échéancier",
|
||||||
|
steps: [
|
||||||
|
// Formulaire d'export : mois courant, tous les modes de paiement, HT et TTC
|
||||||
|
{ method: "POST", path: "/api/payment-schedule/export", body: { groupId: "1", month: month(0), paymentMethods: ALL_METHODS, includeHT: true, includeTTC: true } },
|
||||||
|
{ method: "POST", path: "/api/payment-schedule/export", body: { groupId: 1, month: month(0), paymentMethods: ["virement", "cheque"], includeHT: false, includeTTC: true } },
|
||||||
|
{ method: "POST", path: "/api/payment-schedule/export", body: { groupId: 1, month: month(1), paymentMethods: ALL_METHODS, includeHT: "non", includeTTC: 0 } },
|
||||||
|
{ method: "POST", path: "/api/payment-schedule/export", body: { groupId: 2, month: month(0), paymentMethods: ALL_METHODS, includeHT: true } },
|
||||||
|
{ method: "POST", path: "/api/payment-schedule/export", body: { groupId: 3, month: month(0), paymentMethods: ALL_METHODS, includeTTC: true } },
|
||||||
|
{ method: "POST", path: "/api/payment-schedule/export", body: { groupId: 1, month: "2020-13", paymentMethods: ALL_METHODS, includeHT: true } },
|
||||||
|
{ method: "POST", path: "/api/payment-schedule/export", body: { groupId: 999, month: month(0), paymentMethods: ALL_METHODS } },
|
||||||
|
],
|
||||||
|
tables: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "payment-schedule",
|
||||||
|
name: "export CSV : paramètres invalides",
|
||||||
|
accounts: ["admin", "directeur", "manager"],
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/payment-schedule/export", body: {} },
|
||||||
|
{ method: "POST", path: "/api/payment-schedule/export", body: { groupId: "abc", month: month(0), paymentMethods: ALL_METHODS } },
|
||||||
|
{ method: "POST", path: "/api/payment-schedule/export", body: { groupId: 1.5, month: month(0), paymentMethods: ALL_METHODS } },
|
||||||
|
{ method: "POST", path: "/api/payment-schedule/export", body: { groupId: 1, month: "2026-1", paymentMethods: ALL_METHODS } },
|
||||||
|
{ method: "POST", path: "/api/payment-schedule/export", body: { groupId: 1, month: 202610, paymentMethods: ALL_METHODS } },
|
||||||
|
{ method: "POST", path: "/api/payment-schedule/export", body: { groupId: 1, month: month(0), paymentMethods: [] } },
|
||||||
|
{ method: "POST", path: "/api/payment-schedule/export", body: { groupId: 1, month: month(0), paymentMethods: "virement" } },
|
||||||
|
{ method: "POST", path: "/api/payment-schedule/export", body: { groupId: 1, month: month(0), paymentMethods: ["virement", 5, null] } },
|
||||||
|
{ method: "POST", path: "/api/payment-schedule/export", body: { groupId: 1, month: month(0) } },
|
||||||
|
],
|
||||||
|
tables: [],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------- Envoi d'une facture au webhook (BLReconciliation.tsx) ----------
|
||||||
|
{
|
||||||
|
module: "send-invoice",
|
||||||
|
name: "envoyer une facture au webhook du magasin",
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: "/api/reconciliation/send-invoice",
|
||||||
|
bodyType: "multipart",
|
||||||
|
body: { webhookUrl: `${STUB_URL}/webhook/avoirs`, supplier: "Lactalis", blNumber: "BL-1001", type: "Facture" },
|
||||||
|
files: INVOICE_FILE,
|
||||||
|
},
|
||||||
|
// Webhook BAP configuré ; champs facultatifs absents
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: "/api/reconciliation/send-invoice",
|
||||||
|
bodyType: "multipart",
|
||||||
|
body: { webhookUrl: `${STUB_URL}/webhook/bap` },
|
||||||
|
files: [{ field: "file", filename: "scan échéance.png", contentType: "image/PNG", size: 10 }],
|
||||||
|
},
|
||||||
|
// Fichier ou adresse manquants
|
||||||
|
{ method: "POST", path: "/api/reconciliation/send-invoice", bodyType: "multipart", body: { webhookUrl: `${STUB_URL}/webhook/avoirs`, supplier: "Lactalis" } },
|
||||||
|
{ method: "POST", path: "/api/reconciliation/send-invoice", bodyType: "multipart", body: { supplier: "Lactalis" }, files: INVOICE_FILE },
|
||||||
|
{ method: "POST", path: "/api/reconciliation/send-invoice", bodyType: "multipart", body: { webhookUrl: "" }, files: INVOICE_FILE },
|
||||||
|
// Champ « file » en texte
|
||||||
|
{ method: "POST", path: "/api/reconciliation/send-invoice", bodyType: "multipart", body: { file: "pas un fichier", webhookUrl: `${STUB_URL}/webhook/avoirs` } },
|
||||||
|
{ method: "POST", path: "/api/reconciliation/send-invoice", bodyType: "multipart", body: { file: "", webhookUrl: `${STUB_URL}/webhook/avoirs` } },
|
||||||
|
// Pas de multipart
|
||||||
|
{ method: "POST", path: "/api/reconciliation/send-invoice", body: { webhookUrl: `${STUB_URL}/webhook/avoirs` } },
|
||||||
|
{ method: "POST", path: "/api/reconciliation/send-invoice" },
|
||||||
|
],
|
||||||
|
tables: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "send-invoice",
|
||||||
|
name: "webhook de facture en erreur ou injoignable",
|
||||||
|
accounts: ["admin"],
|
||||||
|
steps: [
|
||||||
|
// Adresses enregistrées sur la fiche du magasin d'Épinal
|
||||||
|
{ method: "PUT", path: "/api/groups/3", body: { webhookUrl: `${STUB_URL}/inconnu` } },
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: "/api/reconciliation/send-invoice",
|
||||||
|
bodyType: "multipart",
|
||||||
|
body: { webhookUrl: `${STUB_URL}/inconnu`, supplier: "Jardin & Co", blNumber: "", type: "Avoir" },
|
||||||
|
files: INVOICE_FILE,
|
||||||
|
},
|
||||||
|
{ method: "PUT", path: "/api/groups/3", body: { webhookUrl: "http://127.0.0.1:1/webhook" } },
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: "/api/reconciliation/send-invoice",
|
||||||
|
bodyType: "multipart",
|
||||||
|
body: { webhookUrl: "http://127.0.0.1:1/webhook", supplier: "Jardin & Co" },
|
||||||
|
files: INVOICE_FILE,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tables: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// RAPPRO-21 : Node envoie le fichier à toute adresse fournie par le navigateur
|
||||||
|
module: "send-invoice",
|
||||||
|
name: "envoyer une facture à une adresse non configurée",
|
||||||
|
// Les autres comptes sont refusés avant (403), comme dans le premier scénario
|
||||||
|
accounts: ["admin", "directeur"],
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: "/api/reconciliation/send-invoice",
|
||||||
|
bodyType: "multipart",
|
||||||
|
body: { webhookUrl: `${STUB_URL}/webhook/non-configure`, supplier: "Lactalis", blNumber: "BL-1001" },
|
||||||
|
files: INVOICE_FILE,
|
||||||
|
},
|
||||||
|
// Webhook BAP désactivé (par l'admin) : son adresse n'est plus acceptée
|
||||||
|
{ method: "POST", path: "/api/webhook-bap-config", body: { webhookUrl: `${STUB_URL}/webhook/bap`, isActive: false } },
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: "/api/reconciliation/send-invoice",
|
||||||
|
bodyType: "multipart",
|
||||||
|
body: { webhookUrl: `${STUB_URL}/webhook/bap`, supplier: "Lactalis" },
|
||||||
|
files: INVOICE_FILE,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tables: [],
|
||||||
|
deviation: {
|
||||||
|
fields: ["status", "body", "appels"],
|
||||||
|
reason:
|
||||||
|
"RAPPRO-21 : l'adresse du webhook doit être celle d'un magasin ou du webhook BAP actif ; toute autre adresse est refusée (400) au lieu d'être appelée par le serveur",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------- Administration : SQL et migration d'urgence (SQLExecutor.tsx) ----------
|
||||||
|
{
|
||||||
|
module: "admin-sql",
|
||||||
|
name: "exécuter du SQL : lectures, types de colonnes et erreurs",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/admin/execute-sql", body: { sql: "SELECT id, name, color, nocodb_config_id FROM groups ORDER BY id" } },
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: "/api/admin/execute-sql",
|
||||||
|
body: {
|
||||||
|
sql: "SELECT count(*) AS total, sum(amount) AS somme, min(created_at) AS premier, min(created_at)::date AS jour, bool_and(true) AS vrai, " +
|
||||||
|
"1.5::float8 AS flottant, 2::float8 AS entier, 7::int2 AS petit, 12345678901::int8 AS grand, NULL AS rien, '{\"a\": [1, \"b\"]}'::jsonb AS objet, " +
|
||||||
|
"ARRAY[1, 2] AS tableau, ARRAY['a', NULL] AS textes, 'x'::bytea AS octets, '1 day 02:00'::interval AS duree, " +
|
||||||
|
"'2026-01-01 10:00+02'::timestamptz AS horodate, 'f'::bool AS faux, 'é\"\\\\'::text AS texte FROM avoirs",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ method: "POST", path: "/api/admin/execute-sql", body: { sql: "SELECT 'a' AS x, 'b' AS x, 1" } },
|
||||||
|
{ method: "POST", path: "/api/admin/execute-sql", body: { sql: "SELECT n, n * 1.5 AS demi FROM generate_series(1, 12) AS n" } },
|
||||||
|
{ method: "POST", path: "/api/admin/execute-sql", body: { sql: "SELECT 1 WHERE false" } },
|
||||||
|
{ method: "POST", path: "/api/admin/execute-sql", body: { sql: "SELECT 1 AS a; SELECT 2 AS b" } },
|
||||||
|
{ method: "POST", path: "/api/admin/execute-sql", body: { sql: "SELECT 1 AS a; SELEC 2" } },
|
||||||
|
{ method: "POST", path: "/api/admin/execute-sql", body: { sql: "ANALYZE groups" } },
|
||||||
|
{ method: "POST", path: "/api/admin/execute-sql", body: { sql: " " } },
|
||||||
|
{ method: "POST", path: "/api/admin/execute-sql", body: { sql: "SELEC 1" } },
|
||||||
|
{ method: "POST", path: "/api/admin/execute-sql", body: { sql: "SELECT 1/0" } },
|
||||||
|
{ method: "POST", path: "/api/admin/execute-sql", body: { sql: "" } },
|
||||||
|
{ method: "POST", path: "/api/admin/execute-sql", body: { sql: 5 } },
|
||||||
|
{ method: "POST", path: "/api/admin/execute-sql", body: {} },
|
||||||
|
],
|
||||||
|
tables: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "admin-sql",
|
||||||
|
name: "exécuter du SQL : modification d'une ligne",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/admin/execute-sql", body: { sql: "UPDATE groups SET color = '#123456' WHERE id = 3" } },
|
||||||
|
{ method: "POST", path: "/api/admin/execute-sql", body: { sql: "UPDATE groups SET color = color WHERE id = 999" } },
|
||||||
|
{ method: "POST", path: "/api/admin/execute-sql", body: { sql: "UPDATE avoirs SET comment = 'modifié' WHERE id = 1 RETURNING id, comment, amount" } },
|
||||||
|
{ method: "POST", path: "/api/admin/execute-sql", body: { sql: "SELECT id, color FROM groups WHERE id = 3" } },
|
||||||
|
],
|
||||||
|
tables: ["groups", "avoirs"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "admin-sql",
|
||||||
|
name: "migration d'urgence : secrets en clair chiffrés",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/admin/execute-sql", body: { sql: "UPDATE groups SET smtp_password = 'secret-en-clair' WHERE id = 2" } },
|
||||||
|
{ method: "POST", path: "/api/admin/emergency-migration" },
|
||||||
|
{ method: "POST", path: "/api/admin/execute-sql", body: { sql: "SELECT id, smtp_password LIKE 'enc:v1:%' AS chiffre FROM groups ORDER BY id" } },
|
||||||
|
],
|
||||||
|
tables: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// N'existe que si EMERGENCY_SECRET est défini (sinon 404) : le banc rejoue le
|
||||||
|
// secret de son propre environnement
|
||||||
|
module: "admin-sql",
|
||||||
|
name: "réinitialisation d'urgence du compte admin",
|
||||||
|
accounts: ["anonyme", "manager"],
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/emergency-admin-reset", body: { secret: "mauvais-secret" } },
|
||||||
|
{ method: "POST", path: "/api/emergency-admin-reset", body: {} },
|
||||||
|
{ method: "POST", path: "/api/emergency-admin-reset", body: { secret: process.env.EMERGENCY_SECRET || "secret-absent" } },
|
||||||
|
{ method: "POST", path: "/api/login", body: { username: "admin", password: "admin" } },
|
||||||
|
],
|
||||||
|
tables: ["users"],
|
||||||
|
// Empreinte au sel aléatoire
|
||||||
|
ignore: ["password"],
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
// Lot 4, services partagés : rapprochement BL / factures (commentaires, courriels
|
||||||
|
// fournisseurs, vérification des factures, cache), configuration NocoDB, BAP et
|
||||||
|
// clés de l'API externe. NocoDB, les webhooks et le SMTP sont simulés par stub.ts.
|
||||||
|
import { STUB_SMTP_PORT, STUB_URL } from "../config";
|
||||||
|
import type { ReadCase, WriteScenario } from "../types";
|
||||||
|
|
||||||
|
export const reads: ReadCase[] = [
|
||||||
|
// Commentaires de rapprochement (ReconciliationComments.tsx)
|
||||||
|
{ module: "reconciliation", path: "/api/deliveries/2/reconciliation-comments" },
|
||||||
|
{ module: "reconciliation", path: "/api/deliveries/4/reconciliation-comments" },
|
||||||
|
{ module: "reconciliation", path: "/api/deliveries/6/reconciliation-comments" },
|
||||||
|
{ module: "reconciliation", path: "/api/deliveries/999/reconciliation-comments" },
|
||||||
|
{ module: "reconciliation", path: "/api/deliveries/abc/reconciliation-comments" },
|
||||||
|
// Historique des relances (BLReconciliation.tsx : « ? » seul, ou storeId pour admin et directeur)
|
||||||
|
{ module: "reconciliation", path: "/api/supplier-mail-logs?" },
|
||||||
|
{ module: "reconciliation", path: "/api/supplier-mail-logs?storeId=1" },
|
||||||
|
{ module: "reconciliation", path: "/api/supplier-mail-logs?storeId=2" },
|
||||||
|
{ module: "reconciliation", path: "/api/supplier-mail-logs?deliveryId=2" },
|
||||||
|
{ module: "reconciliation", path: "/api/supplier-mail-logs?deliveryId=abc" },
|
||||||
|
{ module: "reconciliation", path: "/api/supplier-mail-logs?storeId=abc" },
|
||||||
|
// Diagnostic du cache (sans appelant dans l'interface ; supprime au passage une entrée expirée)
|
||||||
|
{ module: "reconciliation", path: "/api/cache/diagnosis" },
|
||||||
|
// Configuration NocoDB (NocoDBConfig.tsx, Groups.tsx)
|
||||||
|
{ module: "nocodb-config", path: "/api/nocodb-config" },
|
||||||
|
{ module: "nocodb-config", path: "/api/nocodb-config/active" },
|
||||||
|
// Webhook BAP (WebhookBAPConfig.tsx, BapUploadModal.tsx)
|
||||||
|
{ module: "bap", path: "/api/webhook-bap-config" },
|
||||||
|
// Clés de l'API externe (ExternalApiSettings.tsx)
|
||||||
|
{ module: "external-api", path: "/api/external-api/keys" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Durée de vie des entrées du cache : relative à l'heure du passage, différente
|
||||||
|
// entre les deux serveurs (vérifiée par les tests unitaires de Rust)
|
||||||
|
const CACHE_IGNORE = ["expires_at"];
|
||||||
|
|
||||||
|
export const writes: WriteScenario[] = [
|
||||||
|
// ---------- Commentaires ----------
|
||||||
|
{
|
||||||
|
module: "reconciliation",
|
||||||
|
name: "commentaires : ajouter, modifier, supprimer",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/deliveries/2/reconciliation-comments", body: { content: "Relance faite", type: "warning" } },
|
||||||
|
{ method: "PUT", path: "/api/reconciliation-comments/{{0.id}}", body: { content: "Relance faite (modifié)", type: "success" } },
|
||||||
|
{ method: "GET", path: "/api/deliveries/2/reconciliation-comments" },
|
||||||
|
{ method: "DELETE", path: "/api/reconciliation-comments/{{0.id}}" },
|
||||||
|
{ method: "POST", path: "/api/deliveries/6/reconciliation-comments", body: { content: "Commentaire à Metz" } },
|
||||||
|
{ method: "PUT", path: "/api/reconciliation-comments/1", body: { content: "Modifié par un autre compte" } },
|
||||||
|
{ method: "DELETE", path: "/api/reconciliation-comments/2" },
|
||||||
|
{ method: "GET", path: "/api/deliveries/4/reconciliation-comments" },
|
||||||
|
],
|
||||||
|
tables: ["reconciliation_comments"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "reconciliation",
|
||||||
|
name: "commentaires : corps invalides et identifiants inconnus",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/deliveries/2/reconciliation-comments", body: {} },
|
||||||
|
{ method: "POST", path: "/api/deliveries/2/reconciliation-comments", body: { content: "x", type: "bizarre" } },
|
||||||
|
{ method: "POST", path: "/api/deliveries/2/reconciliation-comments", body: { content: 5 } },
|
||||||
|
{ method: "POST", path: "/api/deliveries/2/reconciliation-comments", body: { content: "Forcé", deliveryId: 6, groupId: 2, authorId: "admin_local" } },
|
||||||
|
{ method: "POST", path: "/api/deliveries/999/reconciliation-comments", body: { content: "x" } },
|
||||||
|
{ method: "POST", path: "/api/deliveries/abc/reconciliation-comments", body: { content: "x" } },
|
||||||
|
{ method: "PUT", path: "/api/reconciliation-comments/1", body: { type: "inconnu" } },
|
||||||
|
{ method: "PUT", path: "/api/reconciliation-comments/1", body: { deliveryId: 4, authorId: "admin_local" } },
|
||||||
|
{ method: "PUT", path: "/api/reconciliation-comments/2", body: { deliveryId: 99999 } },
|
||||||
|
{ method: "PUT", path: "/api/reconciliation-comments/999", body: {} },
|
||||||
|
{ method: "PUT", path: "/api/reconciliation-comments/abc", body: {} },
|
||||||
|
{ method: "DELETE", path: "/api/reconciliation-comments/999" },
|
||||||
|
{ method: "DELETE", path: "/api/reconciliation-comments/abc" },
|
||||||
|
],
|
||||||
|
tables: ["reconciliation_comments"],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------- Courriel au fournisseur ----------
|
||||||
|
{
|
||||||
|
module: "reconciliation",
|
||||||
|
name: "courriel au fournisseur : envoi et refus",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/deliveries/2/send-supplier-mail" },
|
||||||
|
{ method: "POST", path: "/api/deliveries/4/send-supplier-mail" },
|
||||||
|
{ method: "POST", path: "/api/deliveries/6/send-supplier-mail" },
|
||||||
|
{ method: "POST", path: "/api/deliveries/7/send-supplier-mail" },
|
||||||
|
{ method: "POST", path: "/api/deliveries/999/send-supplier-mail" },
|
||||||
|
{ method: "POST", path: "/api/deliveries/abc/send-supplier-mail" },
|
||||||
|
{ method: "GET", path: "/api/supplier-mail-logs?deliveryId=2" },
|
||||||
|
],
|
||||||
|
tables: ["supplier_mail_logs"],
|
||||||
|
ignore: ["messageId", "message_id"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "reconciliation",
|
||||||
|
name: "courriel au fournisseur : SMTP incomplet ou injoignable",
|
||||||
|
steps: [
|
||||||
|
// PUT /api/groups/:id reste servi par Node (lot 5) : admin et manager
|
||||||
|
{ method: "PUT", path: "/api/groups/2", body: { smtpEnabled: true } },
|
||||||
|
{ method: "POST", path: "/api/deliveries/6/send-supplier-mail" },
|
||||||
|
// Metz : sans logo, sans nom d'expéditeur ni authentification
|
||||||
|
{ method: "PUT", path: "/api/groups/2", body: { smtpHost: " 127.0.0.1 ", smtpPort: STUB_SMTP_PORT, smtpSenderEmail: "metz@banc.test", smtpUser: null } },
|
||||||
|
{ method: "POST", path: "/api/deliveries/6/send-supplier-mail" },
|
||||||
|
// Nancy : serveur injoignable
|
||||||
|
{ method: "PUT", path: "/api/groups/1", body: { smtpPort: 1 } },
|
||||||
|
{ method: "POST", path: "/api/deliveries/2/send-supplier-mail" },
|
||||||
|
{ method: "GET", path: "/api/supplier-mail-logs?" },
|
||||||
|
{ method: "GET", path: "/api/supplier-mail-logs?deliveryId=6&storeId=2" },
|
||||||
|
],
|
||||||
|
tables: ["supplier_mail_logs"],
|
||||||
|
ignore: ["messageId", "message_id"],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------- Vérification des factures ----------
|
||||||
|
{
|
||||||
|
module: "reconciliation",
|
||||||
|
name: "vérifier la facture d'une livraison",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/deliveries/4/verify-invoice", body: { invoiceReference: "FAC-2004", forceRefresh: false } },
|
||||||
|
{ method: "POST", path: "/api/deliveries/4/verify-invoice", body: { invoiceReference: "FAC-2004", forceRefresh: true } },
|
||||||
|
{ method: "POST", path: "/api/deliveries/2/verify-invoice", body: { blNumber: "BL-1002", forceRefresh: false } },
|
||||||
|
{ method: "POST", path: "/api/deliveries/2/verify-invoice", body: { blNumber: "BL-1002" } },
|
||||||
|
{ method: "POST", path: "/api/deliveries/2/verify-invoice", body: { blNumber: "BL-1004" } },
|
||||||
|
{ method: "POST", path: "/api/deliveries/2/verify-invoice", body: { invoiceReference: "FAC-9999", blNumber: "BL-1002" } },
|
||||||
|
{ method: "POST", path: "/api/deliveries/3/verify-invoice", body: { invoiceReference: " BL-1001 " } },
|
||||||
|
{ method: "POST", path: "/api/deliveries/1/verify-invoice", body: { invoiceReference: "FAC-2001" } },
|
||||||
|
{ method: "POST", path: "/api/deliveries/6/verify-invoice", body: { invoiceReference: "FAC-2006", forceRefresh: true } },
|
||||||
|
{ method: "POST", path: "/api/deliveries/7/verify-invoice", body: { invoiceReference: "FAC-3000" } },
|
||||||
|
{ method: "POST", path: "/api/deliveries/8/verify-invoice", body: { invoiceReference: "AV-3001", forceRefresh: true } },
|
||||||
|
],
|
||||||
|
tables: ["deliveries", "invoice_verification_cache"],
|
||||||
|
ignore: CACHE_IGNORE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "reconciliation",
|
||||||
|
name: "vérifier la facture d'une livraison : corps invalides",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/deliveries/4/verify-invoice", body: {} },
|
||||||
|
{ method: "POST", path: "/api/deliveries/4/verify-invoice", body: { invoiceReference: " ", blNumber: "" } },
|
||||||
|
{ method: "POST", path: "/api/deliveries/4/verify-invoice", body: { invoiceReference: 12 } },
|
||||||
|
{ method: "POST", path: "/api/deliveries/4/verify-invoice", body: { invoiceReference: " ", blNumber: true } },
|
||||||
|
{ method: "POST", path: "/api/deliveries/999/verify-invoice", body: { invoiceReference: "FAC-2004" } },
|
||||||
|
{ method: "POST", path: "/api/deliveries/abc/verify-invoice", body: { invoiceReference: "FAC-2004" } },
|
||||||
|
],
|
||||||
|
tables: ["deliveries", "invoice_verification_cache"],
|
||||||
|
ignore: CACHE_IGNORE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "reconciliation",
|
||||||
|
name: "vérifier une facture : NocoDB injoignable, en erreur ou désactivé",
|
||||||
|
steps: [
|
||||||
|
{ method: "PUT", path: "/api/nocodb-config/1", body: { baseUrl: "http://127.0.0.1:1" } },
|
||||||
|
{ method: "POST", path: "/api/deliveries/4/verify-invoice", body: { invoiceReference: "FAC-2004", forceRefresh: true } },
|
||||||
|
{ method: "PUT", path: "/api/nocodb-config/1", body: { baseUrl: `${STUB_URL}/autre` } },
|
||||||
|
{ method: "POST", path: "/api/deliveries/2/verify-invoice", body: { invoiceReference: "FAC-2002", forceRefresh: true } },
|
||||||
|
{ method: "POST", path: "/api/deliveries/2/verify-invoice", body: { blNumber: "BL-1002", forceRefresh: true } },
|
||||||
|
{ method: "PUT", path: "/api/nocodb-config/1", body: { baseUrl: "pas une adresse" } },
|
||||||
|
{ method: "POST", path: "/api/deliveries/4/verify-invoice", body: { invoiceReference: "FAC-2004", forceRefresh: true } },
|
||||||
|
{ method: "PUT", path: "/api/nocodb-config/1", body: { isActive: false } },
|
||||||
|
{ method: "POST", path: "/api/deliveries/4/verify-invoice", body: { invoiceReference: "FAC-2004", forceRefresh: true } },
|
||||||
|
],
|
||||||
|
tables: ["invoice_verification_cache", "nocodb_config"],
|
||||||
|
ignore: CACHE_IGNORE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "reconciliation",
|
||||||
|
name: "vérifier des factures (adresses sans magasin contrôlé)",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/verify-invoice", body: { groupId: 1, invoiceReference: "FAC-2002" } },
|
||||||
|
{ method: "POST", path: "/api/verify-invoice", body: { groupId: "1", invoiceReference: "fac-2002" } },
|
||||||
|
{ method: "POST", path: "/api/verify-invoice", body: { groupId: 2, invoiceReference: "FAC-2001" } },
|
||||||
|
{ method: "POST", path: "/api/verify-invoice", body: { groupId: 3, invoiceReference: "FAC-2001" } },
|
||||||
|
{ method: "POST", path: "/api/verify-invoice", body: { groupId: 99, invoiceReference: "FAC-2001" } },
|
||||||
|
{ method: "POST", path: "/api/verify-invoice", body: { groupId: "abc", invoiceReference: "FAC-2001" } },
|
||||||
|
{ method: "POST", path: "/api/verify-invoice", body: { groupId: 1, invoiceReference: 5 } },
|
||||||
|
{ method: "POST", path: "/api/verify-invoice", body: { groupId: 1, invoiceReference: " " } },
|
||||||
|
{ method: "POST", path: "/api/verify-invoice", body: { groupId: 1 } },
|
||||||
|
{ method: "POST", path: "/api/verify-invoices", body: { invoiceReferences: [{ groupId: 1, invoiceReference: "fac-2001", deliveryId: 10, supplierName: "lactalis" }, { groupId: 3, invoiceReference: "X", deliveryId: 2 }] } },
|
||||||
|
{ method: "POST", path: "/api/verify-invoices", body: { invoiceReferences: [{ groupId: 1, invoiceReference: "FAC-2001", deliveryId: 1, supplierName: "Bonduelle" }] } },
|
||||||
|
{ method: "POST", path: "/api/verify-invoices", body: { invoiceReferences: [{ groupId: 1, deliveryId: 3 }] } },
|
||||||
|
// Une seule clé non numérique par appel : Node les range dans l'ordre où ses
|
||||||
|
// vérifications parallèles se terminent
|
||||||
|
{ method: "POST", path: "/api/verify-invoices", body: { invoiceReferences: [{ deliveryId: "a" }, { groupId: 99, deliveryId: 5 }] } },
|
||||||
|
{ method: "POST", path: "/api/verify-invoices", body: { invoiceReferences: [7] } },
|
||||||
|
{ method: "POST", path: "/api/verify-invoices", body: { invoiceReferences: [{ groupId: "abc", deliveryId: 4 }] } },
|
||||||
|
{ method: "POST", path: "/api/verify-invoices", body: { invoiceReferences: [null] } },
|
||||||
|
{ method: "POST", path: "/api/verify-invoices", body: { invoiceReferences: "FAC-2001" } },
|
||||||
|
],
|
||||||
|
tables: ["invoice_verification_cache"],
|
||||||
|
ignore: CACHE_IGNORE,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------- Cache des factures ----------
|
||||||
|
{
|
||||||
|
module: "reconciliation",
|
||||||
|
name: "cache : marquer rapproché, mise à jour en lot, diagnostic",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/cache/mark-reconciled", body: { invoiceReference: " FAC-2004 ", groupId: 1 } },
|
||||||
|
{ method: "POST", path: "/api/cache/mark-reconciled", body: { invoiceReference: "FAC-1900", groupId: "1" } },
|
||||||
|
{ method: "POST", path: "/api/cache/mark-reconciled", body: { invoiceReference: 5, groupId: 1 } },
|
||||||
|
{ method: "POST", path: "/api/cache/mark-reconciled", body: { invoiceReference: "FAC-2004" } },
|
||||||
|
{ method: "POST", path: "/api/cache/update-reconciled" },
|
||||||
|
{ method: "GET", path: "/api/cache/diagnosis" },
|
||||||
|
],
|
||||||
|
tables: ["invoice_verification_cache"],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------- Configuration NocoDB ----------
|
||||||
|
{
|
||||||
|
module: "nocodb-config",
|
||||||
|
name: "configurations NocoDB : créer, modifier, supprimer",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/nocodb-config", body: { name: "NocoDB secours", baseUrl: `${STUB_URL}/nocodb`, projectId: "psecours", apiToken: "jeton-neuf", description: "Seconde base", isActive: false, createdBy: "pirate" } },
|
||||||
|
{ method: "PUT", path: "/api/nocodb-config/{{0.id}}", body: { name: "NocoDB renommée", apiToken: "jeton-modifié" } },
|
||||||
|
{ method: "PUT", path: "/api/nocodb-config/{{0.id}}", body: {} },
|
||||||
|
{ method: "GET", path: "/api/nocodb-config" },
|
||||||
|
{ method: "GET", path: "/api/nocodb-config/active" },
|
||||||
|
{ method: "DELETE", path: "/api/nocodb-config/{{0.id}}" },
|
||||||
|
{ method: "DELETE", path: "/api/nocodb-config/999" },
|
||||||
|
{ method: "PUT", path: "/api/nocodb-config/999", body: { name: "Absente" } },
|
||||||
|
{ method: "POST", path: "/api/nocodb-config", body: { name: "Sans jeton", baseUrl: "http://x.test", projectId: "p", apiToken: "" } },
|
||||||
|
],
|
||||||
|
tables: ["nocodb_config"],
|
||||||
|
ignore: ["api_token"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "nocodb-config",
|
||||||
|
name: "configurations NocoDB : corps invalides",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/nocodb-config", body: {} },
|
||||||
|
{ method: "POST", path: "/api/nocodb-config", body: { name: "x", baseUrl: 1, projectId: "p", apiToken: "t" } },
|
||||||
|
{ method: "PUT", path: "/api/nocodb-config/1", body: { isActive: "oui" } },
|
||||||
|
{ method: "PUT", path: "/api/nocodb-config/abc", body: {} },
|
||||||
|
{ method: "DELETE", path: "/api/nocodb-config/abc" },
|
||||||
|
],
|
||||||
|
tables: ["nocodb_config"],
|
||||||
|
ignore: ["api_token"],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------- BAP ----------
|
||||||
|
{
|
||||||
|
module: "bap",
|
||||||
|
name: "webhook BAP : configuration",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/webhook-bap-config", body: { name: "BAP banc", webhookUrl: `${STUB_URL}/webhook/bap-nouveau`, description: "Modifiée", isActive: true, id: 7 } },
|
||||||
|
{ method: "GET", path: "/api/webhook-bap-config" },
|
||||||
|
{ method: "POST", path: "/api/webhook-bap-config", body: { webhookUrl: 12 } },
|
||||||
|
{ method: "POST", path: "/api/webhook-bap-config", body: {} },
|
||||||
|
{ method: "POST", path: "/api/webhook-bap-config", body: { name: "x".repeat(101), webhookUrl: "u", isActive: "oui" } },
|
||||||
|
],
|
||||||
|
tables: ["webhook_bap_config"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "bap",
|
||||||
|
name: "webhook BAP : test de connexion",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/webhook-bap-config/test", body: { webhookUrl: `${STUB_URL}/webhook/bap` } },
|
||||||
|
{ method: "POST", path: "/api/webhook-bap-config/test", body: { webhookUrl: `${STUB_URL}/autre` } },
|
||||||
|
{ method: "POST", path: "/api/webhook-bap-config/test", body: {} },
|
||||||
|
{ method: "POST", path: "/api/webhook-bap-config/test", body: { webhookUrl: "pas-une-url" } },
|
||||||
|
{ method: "POST", path: "/api/webhook-bap-config/test", body: { webhookUrl: "http://127.0.0.1:1" } },
|
||||||
|
{ method: "POST", path: "/api/webhook-bap-config/test", body: { webhookUrl: "ftp://x.test/a" } },
|
||||||
|
],
|
||||||
|
tables: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "bap",
|
||||||
|
name: "envoi d'un BAP au webhook",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/bap/send-webhook", body: { pdfBase64: "JVBERi0xLjQKJcOkw7zDtsOf", fileName: "Facture-123.PDF", recipient: "Laurie" } },
|
||||||
|
{ method: "POST", path: "/api/bap/send-webhook", body: { pdfBase64: "JVBERi0xLjQK", fileName: "a.pdf", recipient: "Paul" } },
|
||||||
|
{ method: "POST", path: "/api/bap/send-webhook", body: { pdfBase64: "JVBERi0xLjQK", fileName: "a.txt", recipient: "Jeremy" } },
|
||||||
|
{ method: "POST", path: "/api/bap/send-webhook", body: { pdfBase64: 5, fileName: "a.pdf", recipient: "Jeremy" } },
|
||||||
|
{ method: "POST", path: "/api/bap/send-webhook", body: { pdfBase64: [1, 2], fileName: "a.pdf", recipient: "Jeremy" } },
|
||||||
|
{ method: "POST", path: "/api/bap/send-webhook", body: { pdfBase64: "x", fileName: 5, recipient: "Laurie" } },
|
||||||
|
{ method: "POST", path: "/api/bap/send-webhook", body: { fileName: "a.pdf" } },
|
||||||
|
{ method: "POST", path: "/api/webhook-bap-config", body: { webhookUrl: `${STUB_URL}/autre` } },
|
||||||
|
{ method: "POST", path: "/api/bap/send-webhook", body: { pdfBase64: "JVBERi0xLjQK", fileName: "b.pdf", recipient: "Jeremy" } },
|
||||||
|
{ method: "POST", path: "/api/webhook-bap-config", body: { webhookUrl: `${STUB_URL}/webhook/bap`, isActive: false } },
|
||||||
|
{ method: "POST", path: "/api/bap/send-webhook", body: { pdfBase64: "JVBERi0xLjQK", fileName: "c.pdf", recipient: "Laurie" } },
|
||||||
|
],
|
||||||
|
tables: ["webhook_bap_config"],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------- Clés de l'API externe ----------
|
||||||
|
{
|
||||||
|
module: "external-api",
|
||||||
|
name: "clés d'API : créer, révoquer",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/external-api/keys", body: { name: " Nouvel outil " } },
|
||||||
|
{ method: "DELETE", path: "/api/external-api/keys/{{0.apiKey.id}}" },
|
||||||
|
{ method: "DELETE", path: "/api/external-api/keys/{{0.apiKey.id}}" },
|
||||||
|
{ method: "DELETE", path: "/api/external-api/keys/2" },
|
||||||
|
{ method: "DELETE", path: "/api/external-api/keys/1" },
|
||||||
|
{ method: "DELETE", path: "/api/external-api/keys/1e2" },
|
||||||
|
{ method: "GET", path: "/api/external-api/keys" },
|
||||||
|
],
|
||||||
|
tables: ["external_api_keys"],
|
||||||
|
ignore: ["key", "keyPrefix", "key_prefix", "key_hash"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "external-api",
|
||||||
|
name: "clés d'API : nom ou identifiant invalide",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/external-api/keys", body: {} },
|
||||||
|
{ method: "POST", path: "/api/external-api/keys", body: { name: " " } },
|
||||||
|
{ method: "POST", path: "/api/external-api/keys", body: { name: 5 } },
|
||||||
|
{ method: "POST", path: "/api/external-api/keys", body: { name: "x".repeat(101) } },
|
||||||
|
{ method: "POST", path: "/api/external-api/keys", body: [] },
|
||||||
|
{ method: "DELETE", path: "/api/external-api/keys/abc" },
|
||||||
|
{ method: "DELETE", path: "/api/external-api/keys/0" },
|
||||||
|
{ method: "DELETE", path: "/api/external-api/keys/1.5" },
|
||||||
|
{ method: "DELETE", path: "/api/external-api/keys/99999999999" },
|
||||||
|
],
|
||||||
|
tables: ["external_api_keys"],
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// Toutes les requêtes du banc, une liste par lot (docs/PLAN-MIGRATION-RUST.md)
|
||||||
|
import type { ReadCase, WriteScenario } from "../types";
|
||||||
|
import * as socle from "./socle";
|
||||||
|
import * as referentiel from "./referentiel";
|
||||||
|
import * as informations from "./informations";
|
||||||
|
import * as quotidien from "./quotidien";
|
||||||
|
import * as fournisseursServices from "./fournisseurs-services";
|
||||||
|
import * as fournisseursCommandes from "./fournisseurs-commandes";
|
||||||
|
import * as fournisseursRapprochement from "./fournisseurs-rapprochement";
|
||||||
|
import * as administration from "./administration";
|
||||||
|
|
||||||
|
const modules = [
|
||||||
|
socle,
|
||||||
|
referentiel,
|
||||||
|
informations,
|
||||||
|
quotidien,
|
||||||
|
fournisseursServices,
|
||||||
|
fournisseursCommandes,
|
||||||
|
fournisseursRapprochement,
|
||||||
|
administration,
|
||||||
|
];
|
||||||
|
|
||||||
|
export const READS: ReadCase[] = modules.flatMap((m) => m.reads);
|
||||||
|
export const WRITES: WriteScenario[] = modules.flatMap((m) => m.writes);
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
// Lot 2 : Informations (annonces), Publicités, Météo, Statistiques et
|
||||||
|
// Statistiques détaillées. Paramètres repris des appels de client/src
|
||||||
|
// (AnnouncementCard, Calendar, Publicities, Dashboard, StatsPanel, Groups,
|
||||||
|
// Suppliers, Analytics, WeatherWidget, WeatherSettings).
|
||||||
|
// Les appels réels à Visual Crossing et Nominatim ne sont jamais déclenchés :
|
||||||
|
// la météo du jour vient du cache (seeds/lot2.ts), et les cas de test de
|
||||||
|
// connexion et de géolocalisation s'arrêtent avant l'appel externe.
|
||||||
|
import type { ReadCase, WriteScenario } from "../types";
|
||||||
|
|
||||||
|
const YEAR = new Date().getUTCFullYear();
|
||||||
|
const PERIOD = "startDate={{today-30}}T00:00:00.000Z&endDate={{today}}T23:59:59.999Z";
|
||||||
|
const YEAR_PERIOD = "startDate={{year}}-01-01T00:00:00.000Z&endDate={{year}}-12-31T23:59:59.999Z";
|
||||||
|
|
||||||
|
const announcementReads: ReadCase[] = [
|
||||||
|
{ module: "announcements", path: "/api/announcements" },
|
||||||
|
{ module: "announcements", path: "/api/announcements?storeId=1" },
|
||||||
|
{ module: "announcements", path: "/api/announcements?storeId=3" },
|
||||||
|
// Magasin illisible : erreur SQL, repli de Node sur toutes les informations
|
||||||
|
{ module: "announcements", path: "/api/announcements?storeId=abc" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const publicityReads: ReadCase[] = [
|
||||||
|
{ module: "publicities", path: "/api/ad-campaigns" },
|
||||||
|
{ module: "publicities", path: "/api/ad-campaigns?year={{year}}" },
|
||||||
|
{ module: "publicities", path: "/api/ad-campaigns?year={{year}}&storeId=1" },
|
||||||
|
{ module: "publicities", path: "/api/ad-campaigns?year={{year}}&storeId=2" },
|
||||||
|
{ module: "publicities", path: "/api/ad-campaigns?year={{year-1}}" },
|
||||||
|
{ module: "publicities", path: "/api/ad-campaigns?year={{year+1}}" },
|
||||||
|
{ module: "publicities", path: "/api/ad-campaigns?storeId=3" },
|
||||||
|
{ module: "publicities", path: "/api/ad-campaigns?year=abc" },
|
||||||
|
{ module: "publicities", path: "/api/ad-campaigns?year=99999999999" },
|
||||||
|
{ module: "publicities", path: "/api/ad-campaigns/1" },
|
||||||
|
{ module: "publicities", path: "/api/ad-campaigns/3" },
|
||||||
|
{ module: "publicities", path: "/api/ad-campaigns/200" },
|
||||||
|
{ module: "publicities", path: "/api/ad-campaigns/202" },
|
||||||
|
{ module: "publicities", path: "/api/ad-campaigns/999" },
|
||||||
|
{ module: "publicities", path: "/api/ad-campaigns/abc" },
|
||||||
|
{ module: "publicities", path: "/api/ad-campaigns/debug" },
|
||||||
|
{ module: "publicities", path: "/api/ad-campaigns/debug?year={{year}}&storeId=1" },
|
||||||
|
{ module: "publicities", path: "/api/ad-campaigns/debug?year=99999999999" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const weatherReads: ReadCase[] = [
|
||||||
|
{ module: "weather", path: "/api/weather/settings" },
|
||||||
|
{ module: "weather", path: "/api/weather/current" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const statsReads: ReadCase[] = [
|
||||||
|
{ module: "stats", path: "/api/stats/by-group" },
|
||||||
|
{ module: "stats", path: "/api/stats/by-group?storeId=1" },
|
||||||
|
{ module: "stats", path: "/api/stats/by-group?storeId=2" },
|
||||||
|
{ module: "stats", path: "/api/stats/by-group?storeId=abc" },
|
||||||
|
{ module: "stats", path: "/api/stats/by-supplier" },
|
||||||
|
{ module: "stats", path: "/api/stats/by-supplier?storeId=1" },
|
||||||
|
{ module: "stats", path: "/api/stats/by-supplier?storeId=3" },
|
||||||
|
{ module: "stats", path: "/api/stats/by-supplier?storeId=abc" },
|
||||||
|
{ module: "stats", path: "/api/stats/monthly" },
|
||||||
|
{ module: "stats", path: "/api/stats/monthly?year={{year}}&month={{month}}" },
|
||||||
|
{ module: "stats", path: "/api/stats/monthly?year={{year}}&month={{month}}&storeId=1" },
|
||||||
|
{ module: "stats", path: "/api/stats/monthly?year={{year}}&month={{month}}&storeId=2" },
|
||||||
|
{ module: "stats", path: "/api/stats/monthly?year={{year}}&month={{month}}&storeId=abc" },
|
||||||
|
{ module: "stats", path: "/api/stats/monthly?year={{year-1}}&month={{month}}" },
|
||||||
|
{ module: "stats", path: "/api/stats/monthly?year={{year}}&month=12" },
|
||||||
|
{ module: "stats", path: "/api/stats/monthly?year={{year}}&month=13" },
|
||||||
|
{ module: "stats", path: "/api/stats/monthly?year=abc&month=3" },
|
||||||
|
{ module: "stats", path: "/api/stats/yearly" },
|
||||||
|
{ module: "stats", path: "/api/stats/yearly?year={{year}}" },
|
||||||
|
{ module: "stats", path: "/api/stats/yearly?year={{year}}&storeId=1" },
|
||||||
|
{ module: "stats", path: "/api/stats/yearly?year={{year}}&storeId=3" },
|
||||||
|
{ module: "stats", path: "/api/stats/yearly?year={{year-1}}" },
|
||||||
|
{ module: "stats", path: "/api/stats/yearly?year=abc" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const analyticsReads: ReadCase[] = [
|
||||||
|
{ module: "analytics", path: "/api/analytics/summary" },
|
||||||
|
{ module: "analytics", path: `/api/analytics/summary?${PERIOD}` },
|
||||||
|
{ module: "analytics", path: `/api/analytics/summary?${YEAR_PERIOD}&supplierIds=1,2&groupIds=1` },
|
||||||
|
{ module: "analytics", path: `/api/analytics/summary?${PERIOD}&groupIds=2` },
|
||||||
|
{ module: "analytics", path: `/api/analytics/summary?${PERIOD}&status=delivered` },
|
||||||
|
{ module: "analytics", path: "/api/analytics/summary?startDate={{today+500}}T00:00:00.000Z" },
|
||||||
|
{ module: "analytics", path: "/api/analytics/summary?supplierIds=abc" },
|
||||||
|
{ module: "analytics", path: "/api/analytics/summary?groupIds=1.5" },
|
||||||
|
{ module: "analytics", path: "/api/analytics/summary?startDate=pas-une-date" },
|
||||||
|
{ module: "analytics", path: `/api/analytics/timeseries?${PERIOD}&granularity=day` },
|
||||||
|
{ module: "analytics", path: `/api/analytics/timeseries?${YEAR_PERIOD}&granularity=week` },
|
||||||
|
{ module: "analytics", path: `/api/analytics/timeseries?${YEAR_PERIOD}&granularity=month&groupIds=1,2` },
|
||||||
|
{ module: "analytics", path: "/api/analytics/timeseries" },
|
||||||
|
{ module: "analytics", path: "/api/analytics/timeseries?granularity=autre&supplierIds=1,4" },
|
||||||
|
{ module: "analytics", path: "/api/analytics/timeseries?supplierIds=1.5,2" },
|
||||||
|
{ module: "analytics", path: "/api/analytics/timeseries?supplierIds=abc" },
|
||||||
|
{ module: "analytics", path: "/api/analytics/timeseries?endDate=pas-une-date" },
|
||||||
|
{ module: "analytics", path: "/api/analytics/by-supplier" },
|
||||||
|
{ module: "analytics", path: `/api/analytics/by-supplier?${PERIOD}` },
|
||||||
|
{ module: "analytics", path: `/api/analytics/by-supplier?${YEAR_PERIOD}&groupIds=1,3` },
|
||||||
|
{ module: "analytics", path: "/api/analytics/by-supplier?groupIds=abc" },
|
||||||
|
{ module: "analytics", path: "/api/analytics/by-store" },
|
||||||
|
{ module: "analytics", path: `/api/analytics/by-store?${PERIOD}&supplierIds=1,2` },
|
||||||
|
{ module: "analytics", path: "/api/analytics/by-store?supplierIds=abc" },
|
||||||
|
{ module: "analytics", path: "/api/analytics/by-store?startDate=pas-une-date" },
|
||||||
|
{ module: "analytics", path: `/api/analytics/export?${PERIOD}&type=summary` },
|
||||||
|
{ module: "analytics", path: "/api/analytics/export" },
|
||||||
|
{ module: "analytics", path: `/api/analytics/export?${YEAR_PERIOD}&type=timeseries` },
|
||||||
|
{ module: "analytics", path: `/api/analytics/export?${PERIOD}&groupIds=1&type=suppliers` },
|
||||||
|
{ module: "analytics", path: "/api/analytics/export?type=stores&supplierIds=2" },
|
||||||
|
{ module: "analytics", path: "/api/analytics/export?type=inconnu" },
|
||||||
|
{ module: "analytics", path: "/api/analytics/export?type=timeseries&startDate={{today+500}}T00:00:00.000Z" },
|
||||||
|
{ module: "analytics", path: "/api/analytics/export?type=stores&supplierIds=abc" },
|
||||||
|
{ module: "analytics", path: "/api/analytics/export?type=suppliers&groupIds=abc" },
|
||||||
|
{ module: "analytics", path: "/api/analytics/export?type=r%C3%A9sum%C3%A9" },
|
||||||
|
{ module: "analytics", path: "/api/analytics/export?type=%E2%82%AC" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const reads: ReadCase[] = [
|
||||||
|
...announcementReads,
|
||||||
|
...publicityReads,
|
||||||
|
...weatherReads,
|
||||||
|
...statsReads,
|
||||||
|
...analyticsReads,
|
||||||
|
];
|
||||||
|
|
||||||
|
const announcementWrites: WriteScenario[] = [
|
||||||
|
{
|
||||||
|
module: "announcements",
|
||||||
|
name: "créer, modifier, supprimer une information",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/announcements", body: { title: "Inventaire tournant", content: "Rayon frais lundi", type: "warning" } },
|
||||||
|
{ method: "POST", path: "/api/announcements", body: { title: "Livraison décalée", content: "Camion attendu à 14 h", storeId: 1 } },
|
||||||
|
{ method: "POST", path: "/api/announcements", body: { title: "Autre magasin", content: "Pour Metz", type: "", storeId: 2, createdBy: "pirate" } },
|
||||||
|
{ method: "PUT", path: "/api/announcements/{{0.id}}", body: { title: "Inventaire reporté", type: "error", storeId: null } },
|
||||||
|
{ method: "PUT", path: "/api/announcements/202", body: { content: "Réunion déplacée", storeId: 3 } },
|
||||||
|
{ method: "DELETE", path: "/api/announcements/{{1.id}}" },
|
||||||
|
{ method: "DELETE", path: "/api/announcements/3" },
|
||||||
|
{ method: "GET", path: "/api/announcements" },
|
||||||
|
],
|
||||||
|
tables: ["dashboard_messages"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "announcements",
|
||||||
|
name: "information invalide ou introuvable",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/announcements", body: {} },
|
||||||
|
{ method: "POST", path: "/api/announcements", body: { title: "x".repeat(300), content: 5 } },
|
||||||
|
{ method: "POST", path: "/api/announcements", body: { title: "Type inconnu", content: "Refusé par la base", type: "urgent" } },
|
||||||
|
{ method: "POST", path: "/api/announcements", body: { title: "Magasin inconnu", content: "Clé étrangère", storeId: 999 } },
|
||||||
|
{ method: "POST", path: "/api/announcements", body: { title: "Magasin en texte", content: "Refusé", storeId: "1" } },
|
||||||
|
{ method: "PUT", path: "/api/announcements/999", body: { title: "Introuvable" } },
|
||||||
|
{ method: "PUT", path: "/api/announcements/abc", body: { title: "Illisible" } },
|
||||||
|
{ method: "PUT", path: "/api/announcements/1", body: {} },
|
||||||
|
{ method: "PUT", path: "/api/announcements/1", body: { type: 5, storeId: "2" } },
|
||||||
|
{ method: "PUT", path: "/api/announcements/1", body: { type: "urgent" } },
|
||||||
|
{ method: "DELETE", path: "/api/announcements/999" },
|
||||||
|
{ method: "DELETE", path: "/api/announcements/abc" },
|
||||||
|
],
|
||||||
|
tables: ["dashboard_messages"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const publicityWrites: WriteScenario[] = [
|
||||||
|
{
|
||||||
|
module: "publicities",
|
||||||
|
name: "créer, modifier, supprimer une publicité",
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
method: "POST", path: "/api/ad-campaigns",
|
||||||
|
body: { pubNumber: "PUB-50", designation: "Fête des jardins", startDate: "{{today+40}}", endDate: "{{today+50}}", year: YEAR, participatingGroups: [1, 2] },
|
||||||
|
},
|
||||||
|
{ method: "PUT", path: "/api/ad-campaigns/{{0.id}}", body: { designation: "Fête des jardins (prolongée)", endDate: "{{today+55}}", participatingGroups: [3] } },
|
||||||
|
{ method: "PUT", path: "/api/ad-campaigns/1", body: { pubNumber: "PUB-01", year: String(YEAR), participatingGroups: [] } },
|
||||||
|
{ method: "PUT", path: "/api/ad-campaigns/2", body: { designation: ["Spécial", "jardin"], createdBy: "bench_manager", inconnu: 1 } },
|
||||||
|
{ method: "PUT", path: "/api/ad-campaigns/202", body: { participatingGroups: 5 } },
|
||||||
|
{ method: "GET", path: "/api/ad-campaigns/{{0.id}}" },
|
||||||
|
{ method: "POST", path: "/api/ad-campaigns/{{0.id}}/delete", body: {} },
|
||||||
|
{ method: "DELETE", path: "/api/ad-campaigns/3" },
|
||||||
|
{ method: "GET", path: "/api/ad-campaigns?year={{year}}" },
|
||||||
|
],
|
||||||
|
tables: ["publicities", "publicity_participations"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "publicities",
|
||||||
|
name: "publicité invalide, en double ou introuvable",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/ad-campaigns", body: {} },
|
||||||
|
{ method: "POST", path: "/api/ad-campaigns", body: { pubNumber: "PUB-01", designation: "Doublon", startDate: "{{today}}", endDate: "{{today+7}}", year: YEAR } },
|
||||||
|
{ method: "POST", path: "/api/ad-campaigns", body: { pubNumber: "PUB-60", designation: "Magasin inconnu", startDate: "{{today}}", endDate: "{{today+7}}", year: YEAR, participatingGroups: [1, 999] } },
|
||||||
|
{ method: "POST", path: "/api/ad-campaigns", body: { pubNumber: "PUB-61", designation: "Groupes en texte", startDate: "{{today}}", endDate: "{{today+7}}", year: YEAR, participatingGroups: "12" } },
|
||||||
|
{ method: "POST", path: "/api/ad-campaigns", body: { pubNumber: "PUB-62", designation: "Date illisible", startDate: "bientôt", endDate: "{{today+7}}", year: YEAR } },
|
||||||
|
{ method: "PUT", path: "/api/ad-campaigns/1", body: { startDate: "pas une date" } },
|
||||||
|
{ method: "PUT", path: "/api/ad-campaigns/1", body: { createdAt: "2020-01-01" } },
|
||||||
|
{ method: "PUT", path: "/api/ad-campaigns/1", body: { pubNumber: "PUB-02" } },
|
||||||
|
{ method: "PUT", path: "/api/ad-campaigns/1", body: { year: 2030.5 } },
|
||||||
|
{ method: "PUT", path: "/api/ad-campaigns/1", body: { designation: null } },
|
||||||
|
{ method: "PUT", path: "/api/ad-campaigns/2", body: { participatingGroups: null } },
|
||||||
|
{ method: "PUT", path: "/api/ad-campaigns/200", body: { participatingGroups: [2, 2] } },
|
||||||
|
{ method: "PUT", path: "/api/ad-campaigns/999", body: { designation: "Introuvable" } },
|
||||||
|
{ method: "PUT", path: "/api/ad-campaigns/998", body: { participatingGroups: [1] } },
|
||||||
|
{ method: "PUT", path: "/api/ad-campaigns/abc", body: { designation: "Illisible" } },
|
||||||
|
{ method: "PUT", path: "/api/ad-campaigns/201", body: {} },
|
||||||
|
{ method: "DELETE", path: "/api/ad-campaigns/999" },
|
||||||
|
{ method: "DELETE", path: "/api/ad-campaigns/abc" },
|
||||||
|
{ method: "POST", path: "/api/ad-campaigns/99999999999/delete", body: {} },
|
||||||
|
],
|
||||||
|
tables: ["publicities", "publicity_participations"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const weatherWrites: WriteScenario[] = [
|
||||||
|
{
|
||||||
|
module: "weather",
|
||||||
|
name: "configurer la météo",
|
||||||
|
steps: [
|
||||||
|
{ method: "PUT", path: "/api/weather/settings/1", body: { location: "Metz, France", isActive: true, apiKey: "cle-meteo-modifiee" } },
|
||||||
|
{ method: "GET", path: "/api/weather/current" },
|
||||||
|
{ method: "POST", path: "/api/weather/settings", body: { apiKey: "seconde-cle-meteo", location: "Épinal, France" } },
|
||||||
|
{ method: "GET", path: "/api/weather/settings" },
|
||||||
|
{ method: "PUT", path: "/api/weather/settings/999", body: { location: "Introuvable" } },
|
||||||
|
{ method: "PUT", path: "/api/weather/settings/1", body: {} },
|
||||||
|
{ method: "PUT", path: "/api/weather/settings/1", body: { isActive: false } },
|
||||||
|
{ method: "GET", path: "/api/weather/current" },
|
||||||
|
],
|
||||||
|
tables: ["weather_settings", "weather_data"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "weather",
|
||||||
|
name: "configuration météo invalide",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/weather/settings", body: {} },
|
||||||
|
{ method: "POST", path: "/api/weather/settings", body: { apiKey: 5, isActive: "oui" } },
|
||||||
|
{ method: "PUT", path: "/api/weather/settings/1", body: { isActive: "oui" } },
|
||||||
|
{ method: "PUT", path: "/api/weather/settings/abc", body: { location: "Illisible" } },
|
||||||
|
{ method: "PUT", path: "/api/weather/settings/abc", body: { location: 12 } },
|
||||||
|
],
|
||||||
|
tables: ["weather_settings"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Corps incomplets : refusés avant tout appel à Visual Crossing ou Nominatim
|
||||||
|
module: "weather",
|
||||||
|
name: "test de connexion et géolocalisation incomplets",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/weather/test-connection", body: {} },
|
||||||
|
{ method: "POST", path: "/api/weather/test-connection", body: { apiKey: "cle-de-test-meteo" } },
|
||||||
|
{ method: "POST", path: "/api/weather/test-connection", body: { apiKey: "", location: "Nancy, France" } },
|
||||||
|
{ method: "POST", path: "/api/weather/geolocation", body: {} },
|
||||||
|
{ method: "POST", path: "/api/weather/geolocation", body: { latitude: 0, longitude: 6.18 } },
|
||||||
|
{ method: "POST", path: "/api/weather/geolocation", body: { latitude: 48.69 } },
|
||||||
|
{ method: "POST", path: "/api/weather/geolocation", body: { latitude: "", longitude: "6.18" } },
|
||||||
|
],
|
||||||
|
tables: ["weather_settings", "weather_data"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const writes: WriteScenario[] = [...announcementWrites, ...publicityWrites, ...weatherWrites];
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
// Lot 3 « Au quotidien » : tâches, DLC, commandes clients et SAV.
|
||||||
|
// Lignes du banc par magasin (seed.ts et seeds/lot3.ts) :
|
||||||
|
// Nancy (1) : tâches 1, 2, 3, 301, 302 ; DLC 1 à 5, 301, 302, 303, 305 ;
|
||||||
|
// commandes 1, 2, 3, 301 ; SAV 1, 2, 301
|
||||||
|
// Metz (2) : tâches 4, 5, 303 ; DLC 6, 304 ; commandes 4, 5, 302, 303 ; SAV 3, 302
|
||||||
|
// Épinal (3) : tâche 300 ; DLC 300 ; commande 300 ; SAV 300 (aucun compte du banc sauf l'admin)
|
||||||
|
import type { ReadCase, WriteScenario } from "../types";
|
||||||
|
|
||||||
|
export const reads: ReadCase[] = [
|
||||||
|
// Tâches : sans magasin choisi, un non-admin reçoit une liste vide
|
||||||
|
{ module: "tasks", path: "/api/tasks" },
|
||||||
|
{ module: "tasks", path: "/api/tasks?storeId=1" },
|
||||||
|
{ module: "tasks", path: "/api/tasks?storeId=2" },
|
||||||
|
{ module: "tasks", path: "/api/tasks?storeId=3" },
|
||||||
|
{ module: "tasks", path: "/api/tasks?storeId=abc" },
|
||||||
|
{ module: "tasks", path: "/api/tasks?storeId=99999999999" },
|
||||||
|
{ module: "tasks", path: "/api/tasks?storeId=1.9" },
|
||||||
|
|
||||||
|
// DLC : filtres de DlcPage, de la fenêtre d'alerte et du tableau de bord
|
||||||
|
{ module: "dlc", path: "/api/dlc-products" },
|
||||||
|
{ module: "dlc", path: "/api/dlc-products?storeId=1" },
|
||||||
|
{ module: "dlc", path: "/api/dlc-products?storeId=2" },
|
||||||
|
{ module: "dlc", path: "/api/dlc-products?storeId=3" },
|
||||||
|
{ module: "dlc", path: "/api/dlc-products?storeId=abc" },
|
||||||
|
{ module: "dlc", path: "/api/dlc-products?status=expires" },
|
||||||
|
{ module: "dlc", path: "/api/dlc-products?status=expires&storeId=1" },
|
||||||
|
{ module: "dlc", path: "/api/dlc-products?status=expires_soon" },
|
||||||
|
{ module: "dlc", path: "/api/dlc-products?status=expires_soon&storeId=2" },
|
||||||
|
{ module: "dlc", path: "/api/dlc-products?status=en_cours" },
|
||||||
|
{ module: "dlc", path: "/api/dlc-products?status=valides" },
|
||||||
|
{ module: "dlc", path: "/api/dlc-products?status=all" },
|
||||||
|
{ module: "dlc", path: "/api/dlc-products?status=inconnu" },
|
||||||
|
{ module: "dlc", path: "/api/dlc-products?supplierId=2" },
|
||||||
|
{ module: "dlc", path: "/api/dlc-products?supplierId=all" },
|
||||||
|
{ module: "dlc", path: "/api/dlc-products?supplierId=abc" },
|
||||||
|
{ module: "dlc", path: "/api/dlc-products?supplierId=0" },
|
||||||
|
{ module: "dlc", path: "/api/dlc-products?supplierId=99999999999" },
|
||||||
|
{ module: "dlc", path: "/api/dlc-products?storeId=99999999999" },
|
||||||
|
{ module: "dlc", path: "/api/dlc-products?search=camem" },
|
||||||
|
{ module: "dlc", path: "/api/dlc-products?search=BONDU" },
|
||||||
|
{ module: "dlc", path: "/api/dlc-products?search=%25" },
|
||||||
|
{ module: "dlc", path: "/api/dlc-products?storeId=1&status=expires_soon&supplierId=1&search=e" },
|
||||||
|
{ module: "dlc", path: "/api/dlc-products/stats" },
|
||||||
|
{ module: "dlc", path: "/api/dlc-products/stats?storeId=1" },
|
||||||
|
{ module: "dlc", path: "/api/dlc-products/stats?storeId=2" },
|
||||||
|
{ module: "dlc", path: "/api/dlc-products/stats?storeId=3" },
|
||||||
|
{ module: "dlc", path: "/api/dlc-products/stats?storeId=abc" },
|
||||||
|
{ module: "dlc", path: "/api/dlc-products/1" },
|
||||||
|
{ module: "dlc", path: "/api/dlc-products/6" },
|
||||||
|
{ module: "dlc", path: "/api/dlc-products/300" },
|
||||||
|
{ module: "dlc", path: "/api/dlc-products/303" },
|
||||||
|
{ module: "dlc", path: "/api/dlc-products/9999" },
|
||||||
|
{ module: "dlc", path: "/api/dlc-products/abc" },
|
||||||
|
|
||||||
|
// Commandes clients : la page n'envoie storeId que pour l'admin, les appels à passer pour tous
|
||||||
|
{ module: "customer-orders", path: "/api/customer-orders" },
|
||||||
|
{ module: "customer-orders", path: "/api/customer-orders?storeId=1" },
|
||||||
|
{ module: "customer-orders", path: "/api/customer-orders?storeId=2" },
|
||||||
|
{ module: "customer-orders", path: "/api/customer-orders?storeId=3" },
|
||||||
|
{ module: "customer-orders", path: "/api/customer-orders?storeId=abc" },
|
||||||
|
{ module: "customer-orders", path: "/api/customer-orders?storeId=2.5" },
|
||||||
|
{ module: "customer-orders", path: "/api/customer-orders/pending-calls" },
|
||||||
|
{ module: "customer-orders", path: "/api/customer-orders/pending-calls?storeId=1" },
|
||||||
|
{ module: "customer-orders", path: "/api/customer-orders/pending-calls?storeId=3" },
|
||||||
|
{ module: "customer-orders", path: "/api/customer-orders/pending-calls?storeId=abc" },
|
||||||
|
|
||||||
|
// SAV : filtres de SavTickets (groupId pour l'admin seulement côté interface)
|
||||||
|
{ module: "sav", path: "/api/sav/tickets" },
|
||||||
|
{ module: "sav", path: "/api/sav/tickets?groupId=1" },
|
||||||
|
{ module: "sav", path: "/api/sav/tickets?groupId=3" },
|
||||||
|
{ module: "sav", path: "/api/sav/tickets?groupId=abc" },
|
||||||
|
{ module: "sav", path: "/api/sav/tickets?groupId=0" },
|
||||||
|
{ module: "sav", path: "/api/sav/tickets?groupId=99999999999" },
|
||||||
|
{ module: "sav", path: "/api/sav/tickets?supplierId=99999999999" },
|
||||||
|
{ module: "sav", path: "/api/sav/tickets?endDate={{today}}T23:59:59%2B02:00" },
|
||||||
|
{ module: "sav", path: "/api/sav/tickets?status=nouveau" },
|
||||||
|
{ module: "sav", path: "/api/sav/tickets?priority=critique&groupId=3" },
|
||||||
|
{ module: "sav", path: "/api/sav/tickets?supplierId=4" },
|
||||||
|
{ module: "sav", path: "/api/sav/tickets?supplierId=abc" },
|
||||||
|
{ module: "sav", path: "/api/sav/tickets?status=attente_pieces&priority=normale&supplierId=4&groupId=1" },
|
||||||
|
{ module: "sav", path: "/api/sav/tickets?startDate={{today-10}}&endDate={{today}}" },
|
||||||
|
{ module: "sav", path: "/api/sav/tickets?startDate=pas-une-date" },
|
||||||
|
{ module: "sav", path: "/api/sav/tickets/1" },
|
||||||
|
{ module: "sav", path: "/api/sav/tickets/3" },
|
||||||
|
{ module: "sav", path: "/api/sav/tickets/300" },
|
||||||
|
{ module: "sav", path: "/api/sav/tickets/301" },
|
||||||
|
{ module: "sav", path: "/api/sav/tickets/9999" },
|
||||||
|
{ module: "sav", path: "/api/sav/tickets/abc" },
|
||||||
|
{ module: "sav", path: "/api/sav/stats" },
|
||||||
|
{ module: "sav", path: "/api/sav/stats?groupId=1" },
|
||||||
|
{ module: "sav", path: "/api/sav/stats?groupId=3" },
|
||||||
|
{ module: "sav", path: "/api/sav/stats?groupId=abc" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const writes: WriteScenario[] = [
|
||||||
|
// ---------- Tâches ----------
|
||||||
|
{
|
||||||
|
module: "tasks",
|
||||||
|
name: "créer une tâche, la modifier, la terminer, la supprimer",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/tasks", body: { title: "Nouvelle tâche", description: "", priority: "high", status: "pending", assignedTo: "Sophie", startDate: "{{today+1}}", dueDate: "{{today+3}}", groupId: 1, createdBy: "manager" } },
|
||||||
|
{ method: "PUT", path: "/api/tasks/{{0.id}}", body: { title: "Tâche modifiée", priority: "low", assignedTo: "Marc", startDate: "", dueDate: "{{today+5}}" } },
|
||||||
|
{ method: "POST", path: "/api/tasks/{{0.id}}/complete" },
|
||||||
|
{ method: "GET", path: "/api/tasks?storeId=1" },
|
||||||
|
{ method: "DELETE", path: "/api/tasks/{{0.id}}" },
|
||||||
|
],
|
||||||
|
tables: ["tasks"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "tasks",
|
||||||
|
name: "tâches de son magasin et d'un autre",
|
||||||
|
steps: [
|
||||||
|
{ method: "PUT", path: "/api/tasks/2", body: { status: "completed", description: null, startDate: "{{today}}T14:30", dueDate: null } },
|
||||||
|
{ method: "PUT", path: "/api/tasks/4", body: { title: "Autre magasin" } },
|
||||||
|
{ method: "PUT", path: "/api/tasks/300", body: { title: "Épinal" } },
|
||||||
|
{ method: "POST", path: "/api/tasks/1/complete" },
|
||||||
|
{ method: "POST", path: "/api/tasks/5/complete" },
|
||||||
|
{ method: "POST", path: "/api/tasks/300/complete" },
|
||||||
|
{ method: "DELETE", path: "/api/tasks/3" },
|
||||||
|
{ method: "DELETE", path: "/api/tasks/303" },
|
||||||
|
{ method: "DELETE", path: "/api/tasks/300" },
|
||||||
|
{ method: "PUT", path: "/api/tasks/9999", body: { title: "Absente" } },
|
||||||
|
{ method: "POST", path: "/api/tasks/9999/complete" },
|
||||||
|
{ method: "DELETE", path: "/api/tasks/9999" },
|
||||||
|
{ method: "DELETE", path: "/api/tasks/abc" },
|
||||||
|
{ method: "POST", path: "/api/tasks/abc/complete" },
|
||||||
|
],
|
||||||
|
tables: ["tasks"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "tasks",
|
||||||
|
name: "créer des tâches : magasin par défaut, autre magasin, corps invalides",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/tasks", body: { title: "Sans magasin", assignedTo: "Léa" } },
|
||||||
|
{ method: "POST", path: "/api/tasks", body: { title: "Magasin d'Épinal", assignedTo: "Léa", groupId: 3, dueDate: "{{today+2}}T18:00:00.000Z" } },
|
||||||
|
{ method: "POST", path: "/api/tasks", body: { title: "Magasin en texte", assignedTo: "Léa", groupId: "1", startDate: 0 } },
|
||||||
|
{ method: "POST", path: "/api/tasks", body: { title: "Metz", assignedTo: "Léa", groupId: 2, status: "completed", completedBy: "x" } },
|
||||||
|
{ method: "POST", path: "/api/tasks", body: {} },
|
||||||
|
{ method: "POST", path: "/api/tasks", body: { title: "Priorité inconnue", assignedTo: "Léa", groupId: 1, priority: "urgent" } },
|
||||||
|
{ method: "POST", path: "/api/tasks", body: { title: "Échéance invalide", assignedTo: "Léa", groupId: 1, dueDate: "pas une date" } },
|
||||||
|
{ method: "POST", path: "/api/tasks", body: { title: "Départ invalide", assignedTo: "Léa", groupId: 1, startDate: "2026-13-45" } },
|
||||||
|
{ method: "POST", path: "/api/tasks", body: { title: "Date de création", assignedTo: "Léa", groupId: 1, createdAt: "{{today}}" } },
|
||||||
|
{ method: "POST", path: "/api/tasks", body: { id: 900, title: "Identifiant imposé", assignedTo: "Léa", groupId: 1, inconnu: true } },
|
||||||
|
],
|
||||||
|
tables: ["tasks"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "tasks",
|
||||||
|
name: "modifier une tâche avec des valeurs invalides",
|
||||||
|
steps: [
|
||||||
|
{ method: "PUT", path: "/api/tasks/1", body: { priority: "urgent" } },
|
||||||
|
{ method: "PUT", path: "/api/tasks/1", body: { startDate: "n'importe quoi" } },
|
||||||
|
{ method: "PUT", path: "/api/tasks/1", body: { dueDate: "n'importe quoi" } },
|
||||||
|
{ method: "PUT", path: "/api/tasks/1", body: { title: null } },
|
||||||
|
{ method: "PUT", path: "/api/tasks/1", body: { assignedTo: 42, groupId: 2, createdBy: "pirate" } },
|
||||||
|
{ method: "PUT", path: "/api/tasks/1", body: {} },
|
||||||
|
{ method: "PUT", path: "/api/tasks/abc", body: {} },
|
||||||
|
{ method: "PUT", path: "/api/tasks/99999999999", body: {} },
|
||||||
|
],
|
||||||
|
tables: ["tasks"],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------- DLC ----------
|
||||||
|
{
|
||||||
|
module: "dlc",
|
||||||
|
name: "créer un produit DLC, le modifier, le valider, le supprimer",
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
method: "POST", path: "/api/dlc-products", body: {
|
||||||
|
productName: "Jambon blanc", name: "Jambon blanc", gencode: "3000000000999", supplierId: 1, groupId: 1,
|
||||||
|
dlcDate: "{{today+4}}", expiryDate: "{{today+4}}T00:00:00.000Z", dateType: "dlc", quantity: 1, unit: "unité",
|
||||||
|
location: "Magasin", alertThreshold: 15, status: "en_cours", notes: "", createdBy: "pirate",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ method: "PUT", path: "/api/dlc-products/{{0.id}}", body: { productName: "Jambon supérieur", name: "Jambon supérieur", expiryDate: "{{today+6}}", dlcDate: "{{today+6}}", notes: "Modifié", groupId: 1 } },
|
||||||
|
{ method: "POST", path: "/api/dlc-products/{{0.id}}/validate" },
|
||||||
|
{ method: "GET", path: "/api/dlc-products/{{0.id}}" },
|
||||||
|
{ method: "DELETE", path: "/api/dlc-products/{{0.id}}" },
|
||||||
|
{ method: "GET", path: "/api/dlc-products/{{0.id}}" },
|
||||||
|
],
|
||||||
|
tables: ["dlc_products"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "dlc",
|
||||||
|
name: "actions sur les produits de son magasin",
|
||||||
|
steps: [
|
||||||
|
{ method: "PUT", path: "/api/dlc-products/2/stock-epuise" },
|
||||||
|
{ method: "PUT", path: "/api/dlc-products/4/restore-stock" },
|
||||||
|
{ method: "PUT", path: "/api/dlc-products/2/mark-processed" },
|
||||||
|
{ method: "PUT", path: "/api/dlc-products/5/unmark-processed" },
|
||||||
|
{ method: "POST", path: "/api/dlc-products/1/validate" },
|
||||||
|
{ method: "GET", path: "/api/dlc-products/stats?storeId=1" },
|
||||||
|
{ method: "DELETE", path: "/api/dlc-products/302" },
|
||||||
|
],
|
||||||
|
tables: ["dlc_products"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "dlc",
|
||||||
|
name: "actions sur les produits d'un autre magasin",
|
||||||
|
steps: [
|
||||||
|
{ method: "PUT", path: "/api/dlc-products/6/stock-epuise" },
|
||||||
|
{ method: "PUT", path: "/api/dlc-products/6/mark-processed" },
|
||||||
|
{ method: "POST", path: "/api/dlc-products/304/validate" },
|
||||||
|
{ method: "PUT", path: "/api/dlc-products/300/stock-epuise" },
|
||||||
|
{ method: "PUT", path: "/api/dlc-products/300/restore-stock" },
|
||||||
|
{ method: "PUT", path: "/api/dlc-products/300/mark-processed" },
|
||||||
|
{ method: "PUT", path: "/api/dlc-products/300/unmark-processed" },
|
||||||
|
{ method: "POST", path: "/api/dlc-products/300/validate" },
|
||||||
|
{ method: "PUT", path: "/api/dlc-products/300", body: { notes: "Épinal" } },
|
||||||
|
{ method: "DELETE", path: "/api/dlc-products/300" },
|
||||||
|
{ method: "PUT", path: "/api/dlc-products/6", body: { quantity: 2 } },
|
||||||
|
{ method: "DELETE", path: "/api/dlc-products/6" },
|
||||||
|
{ method: "PUT", path: "/api/dlc-products/9999/stock-epuise" },
|
||||||
|
{ method: "PUT", path: "/api/dlc-products/9999/restore-stock" },
|
||||||
|
{ method: "POST", path: "/api/dlc-products/9999/validate" },
|
||||||
|
{ method: "PUT", path: "/api/dlc-products/9999", body: { notes: "x" } },
|
||||||
|
{ method: "DELETE", path: "/api/dlc-products/abc" },
|
||||||
|
{ method: "PUT", path: "/api/dlc-products/abc/mark-processed" },
|
||||||
|
{ method: "PUT", path: "/api/dlc-products/abc/unmark-processed" },
|
||||||
|
],
|
||||||
|
tables: ["dlc_products"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "dlc",
|
||||||
|
name: "créer et modifier des produits DLC avec des corps invalides",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/dlc-products", body: { groupId: 1 } },
|
||||||
|
{ method: "POST", path: "/api/dlc-products", body: { name: "Texte", supplierId: 1, groupId: "1", expiryDate: "{{today+2}}" } },
|
||||||
|
{ method: "POST", path: "/api/dlc-products", body: { name: "Épinal", supplierId: 1, groupId: 3, expiryDate: "{{today+2}}" } },
|
||||||
|
{ method: "POST", path: "/api/dlc-products", body: { name: "Type inconnu", supplierId: 1, groupId: 1, expiryDate: "{{today+2}}", dateType: "xyz" } },
|
||||||
|
{ method: "POST", path: "/api/dlc-products", body: { name: "Horodatage", supplierId: 1, groupId: 1, expiryDate: "{{today+2}}", validatedAt: "{{today}}" } },
|
||||||
|
{ method: "POST", path: "/api/dlc-products", body: { name: "Minimal", supplierId: 2, groupId: 1, expiryDate: "{{today+9}}", stockEpuise: null, quantity: "7" } },
|
||||||
|
{ method: "PUT", path: "/api/dlc-products/1", body: { status: "inconnu" } },
|
||||||
|
{ method: "PUT", path: "/api/dlc-products/1", body: { expiryDate: null } },
|
||||||
|
{ method: "PUT", path: "/api/dlc-products/1", body: { processedAt: "{{today}}" } },
|
||||||
|
{ method: "PUT", path: "/api/dlc-products/1", body: { updatedAt: "{{today}}", inconnu: 1 } },
|
||||||
|
{ method: "PUT", path: "/api/dlc-products/2", body: { groupId: 3, createdBy: "pirate" } },
|
||||||
|
],
|
||||||
|
tables: ["dlc_products"],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------- Commandes clients ----------
|
||||||
|
{
|
||||||
|
module: "customer-orders",
|
||||||
|
name: "créer une commande client, la modifier, prévenir le client, la supprimer",
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
method: "POST", path: "/api/customer-orders", body: {
|
||||||
|
orderTaker: "Sophie", customerName: "M. Nouveau", customerPhone: "06 12 34 56 78", customerEmail: "nouveau@client.test",
|
||||||
|
productDesignation: "Barbecue", productReference: "BBQ-1", gencode: "3245678909999", quantity: 1, supplierId: 4,
|
||||||
|
status: "En attente de Commande", deposit: 30, isPromotionalPrice: false, groupId: 1, notes: "Livraison",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ method: "PUT", path: "/api/customer-orders/{{0.id}}", body: { status: "Disponible", deposit: "45.5", quantity: 2 } },
|
||||||
|
{ method: "GET", path: "/api/customer-orders/pending-calls" },
|
||||||
|
{ method: "PATCH", path: "/api/customer-orders/{{0.id}}/mark-called", body: { comment: "Message laissé" } },
|
||||||
|
{ method: "PUT", path: "/api/customer-orders/{{0.id}}", body: { customerNotified: false, notifiedAt: null, notifiedComment: null } },
|
||||||
|
{ method: "DELETE", path: "/api/customer-orders/{{0.id}}" },
|
||||||
|
],
|
||||||
|
tables: ["customer_orders"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "customer-orders",
|
||||||
|
name: "commandes clients de son magasin et d'un autre",
|
||||||
|
steps: [
|
||||||
|
{ method: "PATCH", path: "/api/customer-orders/3/mark-called", body: {} },
|
||||||
|
{ method: "PATCH", path: "/api/customer-orders/301/mark-called", body: { comment: "" } },
|
||||||
|
{ method: "PATCH", path: "/api/customer-orders/303/mark-called", body: { comment: "Metz" } },
|
||||||
|
{ method: "PATCH", path: "/api/customer-orders/300/mark-called", body: { comment: "Épinal" } },
|
||||||
|
{ method: "PUT", path: "/api/customer-orders/4", body: { status: "Retiré" } },
|
||||||
|
{ method: "PUT", path: "/api/customer-orders/300", body: { status: "Retiré" } },
|
||||||
|
{ method: "DELETE", path: "/api/customer-orders/5" },
|
||||||
|
{ method: "DELETE", path: "/api/customer-orders/300" },
|
||||||
|
{ method: "DELETE", path: "/api/customer-orders/2" },
|
||||||
|
{ method: "PUT", path: "/api/customer-orders/9999", body: { status: "x" } },
|
||||||
|
{ method: "PATCH", path: "/api/customer-orders/9999/mark-called", body: {} },
|
||||||
|
{ method: "DELETE", path: "/api/customer-orders/9999" },
|
||||||
|
{ method: "PATCH", path: "/api/customer-orders/abc/mark-called", body: {} },
|
||||||
|
{ method: "DELETE", path: "/api/customer-orders/abc" },
|
||||||
|
],
|
||||||
|
tables: ["customer_orders"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "customer-orders",
|
||||||
|
name: "créer et modifier des commandes clients avec des corps invalides",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/customer-orders", body: {} },
|
||||||
|
{ method: "POST", path: "/api/customer-orders", body: { orderTaker: "Léa", customerName: "Texte", customerPhone: "06", productDesignation: "Pelle", gencode: "1", supplierId: 4, groupId: "1" } },
|
||||||
|
{ method: "POST", path: "/api/customer-orders", body: { orderTaker: "Léa", customerName: "Épinal", customerPhone: "06", productDesignation: "Pelle", gencode: "1", supplierId: 4, groupId: 3 } },
|
||||||
|
{ method: "POST", path: "/api/customer-orders", body: { orderTaker: "Léa", customerName: "Horodatage", customerPhone: "06", productDesignation: "Pelle", gencode: "1", supplierId: 4, groupId: 1, notifiedAt: "{{today}}" } },
|
||||||
|
{ method: "POST", path: "/api/customer-orders", body: { orderTaker: "Léa", customerName: "Fournisseur absent", customerPhone: "06", productDesignation: "Pelle", gencode: "1", supplierId: 999, groupId: 1 } },
|
||||||
|
{ method: "PUT", path: "/api/customer-orders/1", body: { quantity: "abc" } },
|
||||||
|
{ method: "PUT", path: "/api/customer-orders/1", body: { createdAt: "{{today}}" } },
|
||||||
|
{ method: "PUT", path: "/api/customer-orders/1", body: { deposit: "12,5" } },
|
||||||
|
{ method: "PUT", path: "/api/customer-orders/1", body: { updatedAt: "2020-01-01", inconnu: 1, notes: ["a", "b"] } },
|
||||||
|
{ method: "PUT", path: "/api/customer-orders/2", body: {} },
|
||||||
|
],
|
||||||
|
tables: ["customer_orders"],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------- SAV ----------
|
||||||
|
{
|
||||||
|
module: "sav",
|
||||||
|
name: "créer un ticket SAV, le modifier, le commenter, le supprimer",
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
method: "POST", path: "/api/sav/tickets", body: {
|
||||||
|
supplierId: 3, groupId: 1, productGencode: "3245678901241", productReference: "PS-18", productDesignation: "Perceuse sans fil",
|
||||||
|
problemType: "defectueux", problemDescription: "Batterie HS", priority: "haute", status: "nouveau", createdBy: "1",
|
||||||
|
clientName: "M. Laurent", clientPhone: "06 22 22 22 22",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ method: "PATCH", path: "/api/sav/tickets/{{0.id}}", body: { status: "en_cours", priority: "critique", resolutionDescription: "Pièce commandée" } },
|
||||||
|
{ method: "POST", path: "/api/sav/tickets/{{0.id}}/history", body: { description: "Appel au fournisseur" } },
|
||||||
|
{ method: "GET", path: "/api/sav/tickets/{{0.id}}" },
|
||||||
|
{ method: "POST", path: "/api/sav/tickets", body: { supplierId: 4, productGencode: "1", productDesignation: "Second ticket", problemDescription: "Sans magasin choisi" } },
|
||||||
|
{ method: "GET", path: "/api/sav/stats" },
|
||||||
|
{ method: "DELETE", path: "/api/sav/tickets/{{0.id}}" },
|
||||||
|
{ method: "DELETE", path: "/api/sav/tickets/{{4.id}}" },
|
||||||
|
],
|
||||||
|
tables: ["sav_tickets"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "sav",
|
||||||
|
name: "tickets SAV de son magasin et d'un autre",
|
||||||
|
steps: [
|
||||||
|
{ method: "PATCH", path: "/api/sav/tickets/1", body: { status: "resolu" } },
|
||||||
|
{ method: "PATCH", path: "/api/sav/tickets/3", body: { priority: "haute" } },
|
||||||
|
{ method: "PATCH", path: "/api/sav/tickets/300", body: { status: "ferme" } },
|
||||||
|
{ method: "POST", path: "/api/sav/tickets/2/history", body: { description: "Relance" } },
|
||||||
|
{ method: "POST", path: "/api/sav/tickets/3/history", body: { description: "Metz" } },
|
||||||
|
{ method: "POST", path: "/api/sav/tickets/300/history", body: {} },
|
||||||
|
{ method: "DELETE", path: "/api/sav/tickets/302" },
|
||||||
|
{ method: "DELETE", path: "/api/sav/tickets/300" },
|
||||||
|
{ method: "PATCH", path: "/api/sav/tickets/9999", body: {} },
|
||||||
|
{ method: "POST", path: "/api/sav/tickets/9999/history", body: {} },
|
||||||
|
{ method: "DELETE", path: "/api/sav/tickets/9999" },
|
||||||
|
{ method: "PATCH", path: "/api/sav/tickets/abc", body: {} },
|
||||||
|
{ method: "DELETE", path: "/api/sav/tickets/abc" },
|
||||||
|
],
|
||||||
|
tables: ["sav_tickets"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "sav",
|
||||||
|
name: "créer et modifier des tickets SAV avec des corps invalides",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/sav/tickets", body: {} },
|
||||||
|
{ method: "POST", path: "/api/sav/tickets", body: { supplierId: null, groupId: "2", productGencode: 12, productDesignation: "x".repeat(501), problemType: "casse", problemDescription: "x", priority: "urgente", status: "ouvert" } },
|
||||||
|
{ method: "POST", path: "/api/sav/tickets", body: { supplierId: 1, groupId: 3, productGencode: "1", productDesignation: "Épinal", problemDescription: "Autre magasin" } },
|
||||||
|
{ method: "POST", path: "/api/sav/tickets", body: { supplierId: 1, groupId: 99, productGencode: "1", productDesignation: "Magasin inconnu", problemDescription: "x" } },
|
||||||
|
{ method: "POST", path: "/api/sav/tickets", body: { supplierId: 999, groupId: 1, productGencode: "1", productDesignation: "Fournisseur inconnu", problemDescription: "x", ticketNumber: "SAV-PIRATE", createdAt: "2020-01-01" } },
|
||||||
|
{ method: "PATCH", path: "/api/sav/tickets/1", body: { status: "inconnu", priority: 3 } },
|
||||||
|
{ method: "PATCH", path: "/api/sav/tickets/1", body: { supplierId: 999 } },
|
||||||
|
{ method: "PATCH", path: "/api/sav/tickets/2", body: { groupId: 3, createdBy: "pirate", clientName: null, ticketNumber: "SAV-PIRATE" } },
|
||||||
|
{ method: "PATCH", path: "/api/sav/tickets/2", body: {} },
|
||||||
|
],
|
||||||
|
tables: ["sav_tickets"],
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import type { ReadCase, WriteScenario } from "../types";
|
||||||
|
|
||||||
|
export const reads: ReadCase[] = [
|
||||||
|
{ module: "contacts", path: "/api/contacts" },
|
||||||
|
{ module: "contacts", path: "/api/contacts?groupId=1" },
|
||||||
|
{ module: "contacts", path: "/api/contacts?groupId=2" },
|
||||||
|
{ module: "suppliers", path: "/api/suppliers" },
|
||||||
|
{ module: "suppliers", path: "/api/suppliers?dlc=true" },
|
||||||
|
{ module: "groups", path: "/api/groups" },
|
||||||
|
{ module: "users", path: "/api/users" },
|
||||||
|
// Express ignore la casse et une « / » finale
|
||||||
|
{ module: "contacts", path: "/api/Contacts?groupId=1" },
|
||||||
|
{ module: "suppliers", path: "/api/suppliers/" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const writes: WriteScenario[] = [
|
||||||
|
{
|
||||||
|
module: "contacts",
|
||||||
|
name: "créer, modifier, supprimer un contact",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/contacts", body: { groupId: 1, name: "Nouveau contact", company: "Société", phone: "06 99 99 99 99", email: "nouveau@banc.test", notes: "Note" } },
|
||||||
|
{ method: "PUT", path: "/api/contacts/{{0.id}}", body: { name: "Contact modifié", phone: "06 88 88 88 88" } },
|
||||||
|
{ method: "PUT", path: "/api/contacts/3", body: { name: "Autre magasin" } },
|
||||||
|
{ method: "DELETE", path: "/api/contacts/{{0.id}}" },
|
||||||
|
{ method: "DELETE", path: "/api/contacts/3" },
|
||||||
|
],
|
||||||
|
tables: ["contacts"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "contacts",
|
||||||
|
name: "créer un contact invalide",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/contacts", body: { groupId: 1 } },
|
||||||
|
{ method: "POST", path: "/api/contacts", body: { groupId: 2, name: "Metz" } },
|
||||||
|
],
|
||||||
|
tables: ["contacts"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "suppliers",
|
||||||
|
name: "créer, modifier, supprimer un fournisseur",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/suppliers", body: { name: "Nouveau fournisseur", contact: "M. Neuf", phone: "01 23 45 67 89", hasDlc: true, email: "neuf@banc.test", codefou: "F099" } },
|
||||||
|
{ method: "PUT", path: "/api/suppliers/{{0.id}}", body: { name: "Fournisseur renommé", automaticReconciliation: true } },
|
||||||
|
{ method: "DELETE", path: "/api/suppliers/{{0.id}}" },
|
||||||
|
{ method: "POST", path: "/api/suppliers", body: { contact: "sans nom" } },
|
||||||
|
],
|
||||||
|
tables: ["suppliers"],
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import type { ReadCase, WriteScenario } from "../types";
|
||||||
|
|
||||||
|
export const reads: ReadCase[] = [
|
||||||
|
{ module: "socle", path: "/", accounts: ["anonyme"] },
|
||||||
|
{ module: "socle", path: "/deliveries", accounts: ["anonyme", "manager"] },
|
||||||
|
{ module: "socle", path: "/favicon.ico", accounts: ["anonyme"] },
|
||||||
|
{ module: "socle", path: "/api/health", accounts: ["anonyme"] },
|
||||||
|
{ module: "socle", path: "/api/adresse-inconnue", accounts: ["anonyme", "admin"] },
|
||||||
|
{ module: "auth", path: "/api/user" },
|
||||||
|
{ module: "auth", path: "/api/default-credentials-check" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const writes: WriteScenario[] = [
|
||||||
|
{
|
||||||
|
module: "auth",
|
||||||
|
name: "déconnexion puis lecture de l'utilisateur",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/logout" },
|
||||||
|
{ method: "GET", path: "/api/user" },
|
||||||
|
],
|
||||||
|
tables: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "auth",
|
||||||
|
name: "connexion refusée",
|
||||||
|
accounts: ["anonyme"],
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/login", body: { username: "manager", password: "mauvais" } },
|
||||||
|
{ method: "POST", path: "/api/login", body: { username: "inconnu", password: "x" } },
|
||||||
|
{ method: "POST", path: "/api/login", body: {} },
|
||||||
|
],
|
||||||
|
tables: ["users"],
|
||||||
|
},
|
||||||
|
// ---------- Requêtes d'écriture d'un autre site (décision R4) ----------
|
||||||
|
{
|
||||||
|
module: "socle",
|
||||||
|
name: "écriture d'un autre site refusée",
|
||||||
|
accounts: ["manager"],
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: "/api/logout",
|
||||||
|
headers: { origin: "https://site-tiers.example", "sec-fetch-site": "cross-site" },
|
||||||
|
},
|
||||||
|
{ method: "POST", path: "/api/logout", headers: { origin: "null" } },
|
||||||
|
// La session est toujours ouverte côté Rust
|
||||||
|
{ method: "GET", path: "/api/user" },
|
||||||
|
],
|
||||||
|
tables: [],
|
||||||
|
deviation: {
|
||||||
|
fields: ["status", "headers", "body"],
|
||||||
|
reason:
|
||||||
|
"R4 : une requête /api qui n'est pas une lecture est refusée (403) quand le navigateur indique qu'elle vient d'un autre site (Sec-Fetch-Site, sinon Origin)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "socle",
|
||||||
|
name: "écriture du même site acceptée",
|
||||||
|
accounts: ["manager"],
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/logout", headers: { "sec-fetch-site": "same-origin" } },
|
||||||
|
{ method: "GET", path: "/api/user" },
|
||||||
|
],
|
||||||
|
tables: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,438 @@
|
|||||||
|
// Banc de comparaison : rejoue les mêmes requêtes sur deux serveurs et compare
|
||||||
|
// statuts, corps JSON, en-têtes utiles, état de la base et appels aux services
|
||||||
|
// externes (docs/PLAN-MIGRATION-RUST.md, « Vérification »).
|
||||||
|
//
|
||||||
|
// npx tsx scripts/bench/compare.ts Node de référence contre Rust
|
||||||
|
// npx tsx scripts/bench/compare.ts --mode node Node contre Node (le banc est-il stable ?)
|
||||||
|
// npx tsx scripts/bench/compare.ts --only dlc,tasks seulement ces modules
|
||||||
|
//
|
||||||
|
// Prérequis : PostgreSQL local, base modèle bench_seed (voir README.md), serveur Node
|
||||||
|
// de référence construit (reference-node.sh), binaire Rust construit (server-rs/target/release).
|
||||||
|
import { spawn, execFileSync, type ChildProcess } from "child_process";
|
||||||
|
import fs from "fs";
|
||||||
|
import os from "os";
|
||||||
|
import path from "path";
|
||||||
|
import pg from "pg";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
import { BENCH_ACCOUNTS, BENCH_API_KEY, BENCH_ENV, DB_PREFIX, LEFT_PORT, RIGHT_PORT, STUB_URL, TEMPLATE_DB } from "./config";
|
||||||
|
import { startStub } from "./stub";
|
||||||
|
import { READS, WRITES } from "./cases";
|
||||||
|
import { ALL_ACCOUNTS, LOGGED_ACCOUNTS, type AccountKey, type Deviation, type ReadCase, type Step, type WriteScenario } from "./types";
|
||||||
|
|
||||||
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||||
|
// Fichiers des sauvegardes déclarées par les seeds (database_backups), copiés
|
||||||
|
// dans le dossier de sauvegardes de chaque serveur
|
||||||
|
const SEED_BACKUPS_DIR = path.join(ROOT, "scripts/bench/seeds/sauvegardes");
|
||||||
|
const PG_ADMIN_URL = process.env.BENCH_PG_URL || "postgres://logiflow_admin:bench@localhost:5432";
|
||||||
|
// Chemin relatif : depuis cette copie de travail (les serveurs sont lancés depuis BENCH_NODE_DIR)
|
||||||
|
const RUST_BIN = path.resolve(ROOT, process.env.RUST_BIN || "server-rs/target/release/logiflow");
|
||||||
|
// Serveur Node de référence construit par reference-node.sh (dist/ et node_modules/)
|
||||||
|
const NODE_DIR = process.env.BENCH_NODE_DIR || path.join(ROOT, ".bench-reference");
|
||||||
|
const WORK_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "logiflow-banc-"));
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const mode = args.includes("--mode") ? args[args.indexOf("--mode") + 1] : "rust";
|
||||||
|
const only = args.includes("--only") ? args[args.indexOf("--only") + 1].split(",") : null;
|
||||||
|
const keep = args.includes("--keep");
|
||||||
|
|
||||||
|
// ---------- Gabarits ----------
|
||||||
|
|
||||||
|
function isoDay(offset: number): string {
|
||||||
|
const date = new Date();
|
||||||
|
date.setUTCDate(date.getUTCDate() + offset);
|
||||||
|
return date.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fill(template: string, results: unknown[]): string {
|
||||||
|
return template.replace(/\{\{([^}]+)\}\}/g, (_, expr: string) => {
|
||||||
|
const day = expr.match(/^today([+-]\d+)?$/);
|
||||||
|
if (day) return isoDay(Number(day[1] || 0));
|
||||||
|
const year = expr.match(/^year([+-]\d+)?$/);
|
||||||
|
if (year) return String(new Date().getUTCFullYear() + Number(year[1] || 0));
|
||||||
|
if (expr === "month") return String(new Date().getUTCMonth() + 1);
|
||||||
|
const ref = expr.match(/^(\d+)\.(.+)$/);
|
||||||
|
if (ref) {
|
||||||
|
const value = ref[2].split(".").reduce<any>((acc, key) => acc?.[key], results[Number(ref[1])]);
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
throw new Error(`Gabarit inconnu : {{${expr}}}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillDeep(value: unknown, results: unknown[]): unknown {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const whole = value.match(/^\{\{(\d+\.[^}]+)\}\}$/);
|
||||||
|
if (whole) {
|
||||||
|
// Valeur entière ({{0.id}}) : garder son type (nombre, booléen…)
|
||||||
|
const [index, ...keys] = whole[1].split(".");
|
||||||
|
return keys.reduce<any>((acc, key) => acc?.[key], results[Number(index)]);
|
||||||
|
}
|
||||||
|
return fill(value, results);
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) return value.map((item) => fillDeep(item, results));
|
||||||
|
if (value && typeof value === "object") {
|
||||||
|
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, fillDeep(item, results)]));
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Serveurs ----------
|
||||||
|
|
||||||
|
interface Target {
|
||||||
|
name: string;
|
||||||
|
baseUrl: string;
|
||||||
|
dbName: string;
|
||||||
|
processes: ChildProcess[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function dbUrl(name: string) {
|
||||||
|
return `${PG_ADMIN_URL}/${name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function psql(database: string, sql: string) {
|
||||||
|
execFileSync("psql", ["-q", "-v", "ON_ERROR_STOP=1", dbUrl(database), "-c", sql], { stdio: ["ignore", "ignore", "inherit"] });
|
||||||
|
}
|
||||||
|
|
||||||
|
function startProcess(label: string, command: string, commandArgs: string[], env: Record<string, string>): ChildProcess {
|
||||||
|
const log = fs.openSync(path.join(WORK_DIR, `${label}.log`), "a");
|
||||||
|
const child = spawn(command, commandArgs, { cwd: NODE_DIR, env: { ...process.env, ...env }, stdio: ["ignore", log, log] });
|
||||||
|
child.on("exit", (code) => {
|
||||||
|
if (code !== null && code !== 0) console.error(` ${label} s'est arrêté (code ${code}), voir ${WORK_DIR}/${label}.log`);
|
||||||
|
});
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitHealthy(url: string, label: string) {
|
||||||
|
for (let i = 0; i < 120; i++) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${url}/api/health`);
|
||||||
|
if (res.ok) return;
|
||||||
|
} catch { /* pas encore prêt */ }
|
||||||
|
await new Promise((r) => setTimeout(r, 500));
|
||||||
|
}
|
||||||
|
throw new Error(`${label} ne répond pas, voir ${WORK_DIR}/${label}.log`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dossier de sauvegardes propre à chaque serveur, avec les fichiers des données du banc
|
||||||
|
function backupDir(port: number): string {
|
||||||
|
const dir = path.join(WORK_DIR, `sauvegardes-${port}`);
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
if (fs.existsSync(SEED_BACKUPS_DIR)) fs.cpSync(SEED_BACKUPS_DIR, dir, { recursive: true });
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodeEnv(port: number, database: string): Record<string, string> {
|
||||||
|
return { ...BENCH_ENV, NODE_ENV: "production", PORT: String(port), DATABASE_URL: dbUrl(database), BACKUP_DIR: backupDir(port) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startTarget(name: string, kind: "node" | "rust", port: number): Promise<Target> {
|
||||||
|
const dbName = `${DB_PREFIX}_${name}`;
|
||||||
|
psql("postgres", `DROP DATABASE IF EXISTS ${dbName} WITH (FORCE)`);
|
||||||
|
psql("postgres", `CREATE DATABASE ${dbName} TEMPLATE ${TEMPLATE_DB}`);
|
||||||
|
const processes: ChildProcess[] = [];
|
||||||
|
if (kind === "node") {
|
||||||
|
processes.push(startProcess(`${name}-node`, "node", ["dist/index.js"], nodeEnv(port, dbName)));
|
||||||
|
} else {
|
||||||
|
processes.push(startProcess(`${name}-rust`, RUST_BIN, [], {
|
||||||
|
...BENCH_ENV,
|
||||||
|
PORT: String(port),
|
||||||
|
DATABASE_URL: dbUrl(dbName),
|
||||||
|
STATIC_DIR: path.join(NODE_DIR, "dist/public"),
|
||||||
|
BACKUP_DIR: backupDir(port),
|
||||||
|
RUST_LOG: "warn",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
const target = { name, baseUrl: `http://127.0.0.1:${port}`, dbName, processes };
|
||||||
|
await waitHealthy(target.baseUrl, name);
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopTarget(target: Target) {
|
||||||
|
for (const child of target.processes) child.kill("SIGTERM");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remise à zéro de la base d'une cible : vider toutes les tables, recharger les données du modèle
|
||||||
|
let seedDump: string | null = null;
|
||||||
|
function resetDatabase(database: string) {
|
||||||
|
if (!seedDump) {
|
||||||
|
seedDump = path.join(WORK_DIR, "donnees.sql");
|
||||||
|
execFileSync("pg_dump", ["--data-only", "--disable-triggers", "-f", seedDump, dbUrl(TEMPLATE_DB)]);
|
||||||
|
}
|
||||||
|
const tables = execFileSync("psql", ["-At", dbUrl(database), "-c",
|
||||||
|
"SELECT string_agg(format('%I', tablename), ', ') FROM pg_tables WHERE schemaname = 'public'"]).toString().trim();
|
||||||
|
psql(database, `TRUNCATE ${tables} RESTART IDENTITY CASCADE`);
|
||||||
|
execFileSync("psql", ["-q", "-v", "ON_ERROR_STOP=1", "-f", seedDump, dbUrl(database)], { stdio: ["ignore", "ignore", "inherit"] });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Requêtes ----------
|
||||||
|
|
||||||
|
interface Recorded {
|
||||||
|
status: number;
|
||||||
|
headers: Record<string, string>;
|
||||||
|
body: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const KEPT_HEADERS = ["content-type", "cache-control", "location", "content-disposition"];
|
||||||
|
|
||||||
|
function describeCookie(header: string): string {
|
||||||
|
// Nom et attributs du cookie, sans sa valeur (propre à chaque session)
|
||||||
|
const [pair, ...attributes] = header.split(";").map((part) => part.trim());
|
||||||
|
const name = pair.split("=")[0];
|
||||||
|
const kept = attributes.filter((a) => !/^expires=/i.test(a)).map((a) => a.toLowerCase()).sort();
|
||||||
|
return [name, ...kept].join("; ");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(baseUrl: string, account: AccountKey): Promise<Record<string, string>> {
|
||||||
|
if (account === "anonyme") return {};
|
||||||
|
if (account === "api") return { "x-api-key": BENCH_API_KEY };
|
||||||
|
const known = BENCH_ACCOUNTS.find((a) => a.key === account)!;
|
||||||
|
const res = await fetch(`${baseUrl}/api/login`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ username: known.username, password: known.password }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`Connexion impossible pour ${account} sur ${baseUrl} : ${res.status}`);
|
||||||
|
const cookie = res.headers.getSetCookie().map((c) => c.split(";")[0]).join("; ");
|
||||||
|
return { cookie };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBody(step: Step, results: unknown[]): { body?: BodyInit; contentType?: string } {
|
||||||
|
if (step.body === undefined && !step.files) return {};
|
||||||
|
const data = fillDeep(step.body ?? {}, results) as Record<string, unknown>;
|
||||||
|
if (step.bodyType === "form") {
|
||||||
|
return { body: new URLSearchParams(data as Record<string, string>).toString(), contentType: "application/x-www-form-urlencoded" };
|
||||||
|
}
|
||||||
|
if (step.bodyType === "multipart") {
|
||||||
|
const form = new FormData();
|
||||||
|
for (const [key, value] of Object.entries(data)) form.append(key, String(value));
|
||||||
|
for (const file of step.files ?? []) {
|
||||||
|
const content = Buffer.alloc(file.size, 0x25); // contenu fixe : « %%%% »
|
||||||
|
form.append(file.field, new Blob([content], { type: file.contentType }), file.filename);
|
||||||
|
}
|
||||||
|
return { body: form };
|
||||||
|
}
|
||||||
|
return { body: JSON.stringify(data), contentType: "application/json" };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function send(baseUrl: string, step: Step, auth: Record<string, string>, results: unknown[]): Promise<Recorded> {
|
||||||
|
const { body, contentType } = buildBody(step, results);
|
||||||
|
const headers: Record<string, string> = { ...auth, ...(step.headers ?? {}) };
|
||||||
|
if (contentType) headers["content-type"] = contentType;
|
||||||
|
const res = await fetch(`${baseUrl}${fill(step.path, results)}`, { method: step.method, headers, body, redirect: "manual" });
|
||||||
|
const kept: Record<string, string> = {};
|
||||||
|
for (const name of KEPT_HEADERS) {
|
||||||
|
const value = res.headers.get(name);
|
||||||
|
if (value !== null) kept[name] = value.replace(/;\s*/g, "; ").toLowerCase();
|
||||||
|
}
|
||||||
|
const cookies = res.headers.getSetCookie();
|
||||||
|
if (cookies.length) kept["set-cookie"] = cookies.map(describeCookie).join(" | ");
|
||||||
|
const text = await res.text();
|
||||||
|
let parsed: unknown = text;
|
||||||
|
if ((res.headers.get("content-type") || "").includes("json")) {
|
||||||
|
try { parsed = JSON.parse(text); } catch { /* texte brut */ }
|
||||||
|
} else if (text.length > 2000) {
|
||||||
|
// Gros contenu non JSON (HTML, fichiers) : taille et début suffisent
|
||||||
|
parsed = { taille: Buffer.byteLength(text), debut: text.slice(0, 200) };
|
||||||
|
}
|
||||||
|
return { status: res.status, headers: kept, body: parsed };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stubCalls(reset = false): Promise<unknown[]> {
|
||||||
|
const res = await fetch(`${STUB_URL}/__calls`, { method: reset ? "DELETE" : "GET" });
|
||||||
|
return res.json() as Promise<unknown[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function snapshotTables(database: string, tables: string[]) {
|
||||||
|
const client = new pg.Client({ connectionString: dbUrl(database) });
|
||||||
|
await client.connect();
|
||||||
|
const out: Record<string, unknown[]> = {};
|
||||||
|
for (const table of tables) {
|
||||||
|
const { rows } = await client.query(`SELECT * FROM ${table} ORDER BY 1, 2`);
|
||||||
|
out[table] = rows;
|
||||||
|
}
|
||||||
|
await client.end();
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Normalisation ----------
|
||||||
|
|
||||||
|
const ISO_DATETIME = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z/g;
|
||||||
|
|
||||||
|
// drop : champs d'un écart voulu, retirés des deux côtés
|
||||||
|
function normalize(value: unknown, window: [number, number], ignore: string[] = [], drop: string[] = []): unknown {
|
||||||
|
if (value instanceof Date) return normalize(value.toISOString(), window, ignore, drop);
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return value.replace(ISO_DATETIME, (match) => {
|
||||||
|
const time = Date.parse(match);
|
||||||
|
return time >= window[0] && time <= window[1] ? "<maintenant>" : match;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) return value.map((item) => normalize(item, window, ignore, drop));
|
||||||
|
if (value && typeof value === "object") {
|
||||||
|
return Object.fromEntries(Object.entries(value).filter(([key]) => !drop.includes(key)).map(([key, item]) =>
|
||||||
|
[key, ignore.includes(key) ? "<ignoré>" : normalize(item, window, ignore, drop)]));
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Passage ----------
|
||||||
|
|
||||||
|
interface Entry {
|
||||||
|
key: string; // module | cas | compte
|
||||||
|
module: string;
|
||||||
|
data: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runTarget(target: Target): Promise<Entry[]> {
|
||||||
|
const entries: Entry[] = [];
|
||||||
|
const startedAt = Date.now() - 60_000;
|
||||||
|
const reads = READS.filter((c) => !only || only.includes(c.module));
|
||||||
|
const writes = WRITES.filter((c) => !only || only.includes(c.module));
|
||||||
|
|
||||||
|
// Lectures : une base fraîche, une connexion par compte
|
||||||
|
resetDatabase(target.dbName);
|
||||||
|
const sessions = new Map<AccountKey, Record<string, string>>();
|
||||||
|
for (const account of [...LOGGED_ACCOUNTS, "anonyme", "api"] as AccountKey[]) {
|
||||||
|
sessions.set(account, await login(target.baseUrl, account));
|
||||||
|
}
|
||||||
|
for (const readCase of reads) {
|
||||||
|
for (const account of readCase.accounts ?? ALL_ACCOUNTS) {
|
||||||
|
await stubCalls(true);
|
||||||
|
const recorded = await send(target.baseUrl, { method: "GET", path: readCase.path }, sessions.get(account)!, []);
|
||||||
|
const calls = await stubCalls();
|
||||||
|
entries.push({
|
||||||
|
key: `${readCase.module} | GET ${readCase.path} | ${account}`,
|
||||||
|
module: readCase.module,
|
||||||
|
data: { reponse: recorded, appels: calls, ignore: readCase.ignore, deviation: readCase.deviation },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Écritures : base remise à zéro pour chaque compte
|
||||||
|
for (const scenario of writes) {
|
||||||
|
for (const account of scenario.accounts ?? LOGGED_ACCOUNTS) {
|
||||||
|
resetDatabase(target.dbName);
|
||||||
|
await stubCalls(true);
|
||||||
|
const auth = await login(target.baseUrl, account);
|
||||||
|
const results: unknown[] = [];
|
||||||
|
const responses: Recorded[] = [];
|
||||||
|
for (const step of scenario.steps) {
|
||||||
|
const recorded = await send(target.baseUrl, step, auth, results);
|
||||||
|
responses.push(recorded);
|
||||||
|
results.push(recorded.body);
|
||||||
|
}
|
||||||
|
entries.push({
|
||||||
|
key: `${scenario.module} | ${scenario.name} | ${account}`,
|
||||||
|
module: scenario.module,
|
||||||
|
data: {
|
||||||
|
reponses: responses,
|
||||||
|
tables: await snapshotTables(target.dbName, scenario.tables),
|
||||||
|
appels: await stubCalls(),
|
||||||
|
ignore: scenario.ignore,
|
||||||
|
deviation: scenario.deviation,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const endedAt = Date.now() + 60_000;
|
||||||
|
return entries.map((entry) => {
|
||||||
|
const { ignore, deviation, ...data } = entry.data as { ignore?: string[]; deviation?: Deviation };
|
||||||
|
return { ...entry, data: normalize(data, [startedAt, endedAt], ignore, deviation?.fields) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Comparaison ----------
|
||||||
|
|
||||||
|
function differences(a: unknown, b: unknown, where = ""): string[] {
|
||||||
|
if (JSON.stringify(a) === JSON.stringify(b)) return [];
|
||||||
|
if (Array.isArray(a) && Array.isArray(b)) {
|
||||||
|
const out: string[] = [];
|
||||||
|
if (a.length !== b.length) out.push(`${where} : ${a.length} éléments contre ${b.length}`);
|
||||||
|
for (let i = 0; i < Math.min(a.length, b.length); i++) out.push(...differences(a[i], b[i], `${where}[${i}]`));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
if (a && b && typeof a === "object" && typeof b === "object" && !Array.isArray(a) && !Array.isArray(b)) {
|
||||||
|
const out: string[] = [];
|
||||||
|
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
|
||||||
|
for (const key of keys) {
|
||||||
|
if (!(key in (a as object))) out.push(`${where}.${key} : absent à gauche, ${JSON.stringify((b as any)[key])?.slice(0, 120)} à droite`);
|
||||||
|
else if (!(key in (b as object))) out.push(`${where}.${key} : ${JSON.stringify((a as any)[key])?.slice(0, 120)} à gauche, absent à droite`);
|
||||||
|
else out.push(...differences((a as any)[key], (b as any)[key], `${where}.${key}`));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
return [`${where} : ${JSON.stringify(a)?.slice(0, 160)} contre ${JSON.stringify(b)?.slice(0, 160)}`];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
// Décision R5 : Rust compte « aujourd'hui » à Paris, Node en UTC. Les deux
|
||||||
|
// dates diffèrent en fin de soirée (après 22 h ou 23 h UTC) : les écarts de
|
||||||
|
// date relevés alors sont attendus.
|
||||||
|
const parisDay = new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Paris" }).format(new Date());
|
||||||
|
if (mode !== "node" && parisDay !== isoDay(0)) {
|
||||||
|
console.log(`Attention : il est déjà le ${parisDay} à Paris (${isoDay(0)} en UTC) ; écarts de date attendus (décision R5). Relancer après minuit UTC.`);
|
||||||
|
}
|
||||||
|
console.log(`Banc : ${mode === "node" ? "Node contre Node" : "Node de référence contre Rust"}${only ? ` (modules : ${only.join(", ")})` : ""}`);
|
||||||
|
console.log(`Journaux : ${WORK_DIR}`);
|
||||||
|
const stopStub = await startStub();
|
||||||
|
const targets: Target[] = [];
|
||||||
|
try {
|
||||||
|
const left = await startTarget("gauche", "node", LEFT_PORT);
|
||||||
|
targets.push(left);
|
||||||
|
const right = await startTarget("droite", mode === "node" ? "node" : "rust", RIGHT_PORT);
|
||||||
|
targets.push(right);
|
||||||
|
|
||||||
|
const leftEntries = await runTarget(left);
|
||||||
|
const rightEntries = await runTarget(right);
|
||||||
|
|
||||||
|
const report: { key: string; differences: string[] }[] = [];
|
||||||
|
const byModule = new Map<string, { total: number; ko: number }>();
|
||||||
|
for (const entry of leftEntries) {
|
||||||
|
const other = rightEntries.find((e) => e.key === entry.key);
|
||||||
|
const diffs = differences(entry.data, other?.data);
|
||||||
|
const stats = byModule.get(entry.module) ?? { total: 0, ko: 0 };
|
||||||
|
stats.total++;
|
||||||
|
if (diffs.length) { stats.ko++; report.push({ key: entry.key, differences: diffs }); }
|
||||||
|
byModule.set(entry.module, stats);
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(path.join(WORK_DIR, "gauche.json"), JSON.stringify(leftEntries, null, 2));
|
||||||
|
fs.writeFileSync(path.join(WORK_DIR, "droite.json"), JSON.stringify(rightEntries, null, 2));
|
||||||
|
fs.writeFileSync(path.join(WORK_DIR, "ecarts.json"), JSON.stringify(report, null, 2));
|
||||||
|
|
||||||
|
for (const item of report.slice(0, 40)) {
|
||||||
|
console.log(`\n✗ ${item.key}`);
|
||||||
|
for (const line of item.differences.slice(0, 8)) console.log(` ${line}`);
|
||||||
|
if (item.differences.length > 8) console.log(` … ${item.differences.length - 8} autre(s)`);
|
||||||
|
}
|
||||||
|
if (report.length > 40) console.log(`\n… ${report.length - 40} autre(s) cas en écart, voir ${WORK_DIR}/ecarts.json`);
|
||||||
|
|
||||||
|
console.log("\nModule cas écarts");
|
||||||
|
for (const [module, stats] of [...byModule].sort()) {
|
||||||
|
console.log(`${module.padEnd(22)} ${String(stats.total).padStart(4)} ${stats.ko}`);
|
||||||
|
}
|
||||||
|
const deviations = new Set(
|
||||||
|
[...READS, ...WRITES]
|
||||||
|
.filter((c) => c.deviation && (!only || only.includes(c.module)))
|
||||||
|
.map((c) => `${c.module} : ${c.deviation!.fields.join(", ")} — ${c.deviation!.reason}`),
|
||||||
|
);
|
||||||
|
if (deviations.size) {
|
||||||
|
console.log("\nÉcarts voulus (champs retirés de la comparaison) :");
|
||||||
|
for (const line of deviations) console.log(` ${line}`);
|
||||||
|
}
|
||||||
|
const total = leftEntries.length;
|
||||||
|
console.log(`\n${total} cas comparés, ${report.length} en écart.`);
|
||||||
|
process.exitCode = report.length ? 1 : 0;
|
||||||
|
} finally {
|
||||||
|
targets.forEach(stopTarget);
|
||||||
|
await stopStub();
|
||||||
|
if (!keep) {
|
||||||
|
for (const target of targets) {
|
||||||
|
try { psql("postgres", `DROP DATABASE IF EXISTS ${target.dbName} WITH (FORCE)`); } catch { /* déjà supprimée */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => { console.error(error); process.exit(1); });
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// Réglages communs du banc de comparaison Node / Rust.
|
||||||
|
|
||||||
|
export interface BenchAccount {
|
||||||
|
key: string;
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
role: "admin" | "directeur" | "manager" | "employee";
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
groupIds: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Un compte par profil à tester (règles communes du plan du webUI)
|
||||||
|
export const BENCH_ACCOUNTS: BenchAccount[] = [
|
||||||
|
{ key: "admin", id: "admin_local", username: "admin", password: "banc-admin", role: "admin", firstName: "Alice", lastName: "Admin", groupIds: [] },
|
||||||
|
{ key: "directeur", id: "bench_directeur", username: "directeur", password: "banc-directeur", role: "directeur", firstName: "Denis", lastName: "Directeur", groupIds: [1, 2] },
|
||||||
|
{ key: "manager", id: "bench_manager", username: "manager", password: "banc-manager", role: "manager", firstName: "Manon", lastName: "Manager", groupIds: [1] },
|
||||||
|
{ key: "employe", id: "bench_employee", username: "employe", password: "banc-employe", role: "employee", firstName: "Émile", lastName: "Employé", groupIds: [1] },
|
||||||
|
{ key: "sans-magasin", id: "bench_sans_magasin", username: "sansmagasin", password: "banc-sans-magasin", role: "employee", firstName: "Sacha", lastName: "Sans", groupIds: [] },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Clé de l'API externe enregistrée par seed.ts (empreinte seulement)
|
||||||
|
export const BENCH_API_KEY = "lf_banc_cle_de_test_0123456789";
|
||||||
|
|
||||||
|
// Plusieurs bancs peuvent tourner en parallèle (un par copie de travail) :
|
||||||
|
// BENCH_ID sépare les bases, BENCH_PORT_BASE les ports.
|
||||||
|
export const BENCH_ID = process.env.BENCH_ID || "";
|
||||||
|
const SUFFIX = BENCH_ID ? `_${BENCH_ID}` : "";
|
||||||
|
const PORT_BASE = Number(process.env.BENCH_PORT_BASE || 3200);
|
||||||
|
|
||||||
|
export const TEMPLATE_DB = process.env.BENCH_TEMPLATE_DB || `bench_seed${SUFFIX}`;
|
||||||
|
export const DB_PREFIX = `bench${SUFFIX}`;
|
||||||
|
export const LEFT_PORT = PORT_BASE + 1;
|
||||||
|
export const RIGHT_PORT = PORT_BASE + 3;
|
||||||
|
|
||||||
|
// Services externes simulés (NocoDB, webhooks, SMTP) : voir stub.ts.
|
||||||
|
// Les magasins de seed.ts pointent vers ces ports.
|
||||||
|
export const STUB_PORT = PORT_BASE + 90;
|
||||||
|
export const STUB_URL = `http://127.0.0.1:${STUB_PORT}`;
|
||||||
|
export const STUB_SMTP_PORT = PORT_BASE + 91;
|
||||||
|
|
||||||
|
// Environnement commun aux deux serveurs
|
||||||
|
export const BENCH_ENV = {
|
||||||
|
SESSION_SECRET: "banc-secret-de-session",
|
||||||
|
ENCRYPTION_KEY: "banc-cle-de-chiffrement",
|
||||||
|
EXTERNAL_API_KEYS: "",
|
||||||
|
TZ: "UTC",
|
||||||
|
};
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// Fonctions reprises de l'ancien serveur Node, pour écrire les données du banc
|
||||||
|
// comme il les aurait écrites (le serveur Node a été retiré du dépôt).
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
|
||||||
|
const PREFIX = "enc:v1:";
|
||||||
|
|
||||||
|
/** Ancien server/crypto.ts : AES-256-GCM, clé = SHA-256(ENCRYPTION_KEY ou SESSION_SECRET) */
|
||||||
|
export function encryptSecret(value: string): string {
|
||||||
|
const secret = process.env.ENCRYPTION_KEY || process.env.SESSION_SECRET || "logiflow-fallback-secret-key";
|
||||||
|
const key = crypto.createHash("sha256").update(secret).digest();
|
||||||
|
const iv = crypto.randomBytes(12);
|
||||||
|
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
|
||||||
|
const encrypted = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
|
||||||
|
const tag = cipher.getAuthTag();
|
||||||
|
return `${PREFIX}${iv.toString("base64")}:${tag.toString("base64")}:${encrypted.toString("base64")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ancien weatherService.getPreviousYearDate, serveur en UTC : même jour de la
|
||||||
|
* semaine, même rang dans le mois, l'année précédente
|
||||||
|
*/
|
||||||
|
export function previousYearDate(date: Date): string {
|
||||||
|
const year = date.getUTCFullYear();
|
||||||
|
const month = date.getUTCMonth();
|
||||||
|
const dayOfWeek = date.getUTCDay();
|
||||||
|
const firstDayOfMonth = new Date(Date.UTC(year - 1, month, 1)).getUTCDay();
|
||||||
|
const daysToAdd = (dayOfWeek - firstDayOfMonth + 7) % 7;
|
||||||
|
const weekOfMonth = Math.ceil(date.getUTCDate() / 7);
|
||||||
|
const target = new Date(Date.UTC(year - 1, month, 1 + daysToAdd + (weekOfMonth - 1) * 7));
|
||||||
|
if (target.getUTCMonth() !== month) {
|
||||||
|
target.setUTCDate(target.getUTCDate() - 7);
|
||||||
|
}
|
||||||
|
return target.toISOString().split("T")[0];
|
||||||
|
}
|
||||||
Executable
+23
@@ -0,0 +1,23 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Construit le serveur Node de référence du banc, depuis le dernier commit qui le
|
||||||
|
# contient (REFERENCE_NODE_COMMIT), dans une copie de travail à part. Le serveur
|
||||||
|
# Node ayant quitté le dépôt, c'est contre lui que Rust est comparé.
|
||||||
|
# Usage : scripts/bench/reference-node.sh [dossier] (défaut : .bench-reference)
|
||||||
|
# Puis : export BENCH_NODE_DIR=<dossier>
|
||||||
|
set -euo pipefail
|
||||||
|
root="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||||
|
commit="$(tr -d '[:space:]' < "$root/scripts/bench/REFERENCE_NODE_COMMIT")"
|
||||||
|
dir="${1:-$root/.bench-reference}"
|
||||||
|
|
||||||
|
if [ ! -d "$dir/.git" ] && [ ! -f "$dir/.git" ]; then
|
||||||
|
git -C "$root" worktree add --detach "$dir" "$commit" > /dev/null
|
||||||
|
else
|
||||||
|
git -C "$dir" checkout --detach -q "$commit"
|
||||||
|
fi
|
||||||
|
cd "$dir"
|
||||||
|
if [ ! -f dist/index.js ] || [ "$(cat dist/.commit 2>/dev/null)" != "$commit" ]; then
|
||||||
|
npm ci --no-audit --no-fund > /dev/null
|
||||||
|
scripts/bench/build-node.sh > /dev/null
|
||||||
|
echo "$commit" > dist/.commit
|
||||||
|
fi
|
||||||
|
echo "$dir"
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// Compare les colonnes déclarées dans shared/schema.ts à celles d'une base réelle.
|
||||||
|
// Usage : DATABASE_URL=postgres://… npx tsx scripts/bench/schema-check.ts
|
||||||
|
import pg from "pg";
|
||||||
|
import * as schema from "../../shared/schema";
|
||||||
|
import { getTableConfig, PgTable } from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const declared = new Map<string, Map<string, string>>();
|
||||||
|
for (const value of Object.values(schema)) {
|
||||||
|
if (!(value instanceof PgTable)) continue;
|
||||||
|
const config = getTableConfig(value);
|
||||||
|
declared.set(config.name, new Map(config.columns.map((c) => [c.name, `${c.getSQLType()}${c.notNull ? " not null" : ""}`])));
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
|
||||||
|
await client.connect();
|
||||||
|
const { rows } = await client.query(
|
||||||
|
`SELECT table_name, column_name, data_type, is_nullable
|
||||||
|
FROM information_schema.columns WHERE table_schema = 'public'`,
|
||||||
|
);
|
||||||
|
await client.end();
|
||||||
|
|
||||||
|
const actual = new Map<string, Map<string, string>>();
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!actual.has(row.table_name)) actual.set(row.table_name, new Map());
|
||||||
|
actual.get(row.table_name)!.set(row.column_name, `${row.data_type}${row.is_nullable === "NO" ? " not null" : ""}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let problems = 0;
|
||||||
|
for (const [table, columns] of declared) {
|
||||||
|
const real = actual.get(table);
|
||||||
|
if (!real) { console.log(`table absente : ${table}`); problems++; continue; }
|
||||||
|
for (const [column, type] of columns) {
|
||||||
|
if (!real.has(column)) { console.log(`colonne absente : ${table}.${column} (${type})`); problems++; }
|
||||||
|
}
|
||||||
|
for (const [column, type] of real) {
|
||||||
|
if (!columns.has(column)) console.log(`colonne en plus dans la base : ${table}.${column} (${type})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const table of actual.keys()) {
|
||||||
|
if (!declared.has(table)) console.log(`table en plus dans la base : ${table}`);
|
||||||
|
}
|
||||||
|
console.log(problems === 0 ? "Schéma conforme à shared/schema.ts" : `${problems} écart(s) bloquant(s)`);
|
||||||
|
process.exit(problems === 0 ? 0 : 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => { console.error(error); process.exit(1); });
|
||||||
Executable
+20
@@ -0,0 +1,20 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Construit, dans une base vide, le schéma que le serveur Node attend :
|
||||||
|
# init.sql, puis les migrations appliquées à la main en production, puis celles
|
||||||
|
# de server/migrations.ts (jamais appelé au démarrage). Le reste (index, colonnes
|
||||||
|
# des magasins, tables récentes) est ajouté par server/migrations.production.ts
|
||||||
|
# au premier démarrage de Node.
|
||||||
|
# Usage : scripts/bench/schema.sh postgres://utilisateur:motdepasse@hôte/base
|
||||||
|
set -euo pipefail
|
||||||
|
url="$1"
|
||||||
|
root="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||||
|
run() { psql -q -v ON_ERROR_STOP=1 "$url" "$@"; }
|
||||||
|
|
||||||
|
run -f "$root/init.sql"
|
||||||
|
run -f "$root/migrations/20260515_add_contacts_and_supplier_email.sql"
|
||||||
|
run -f "$root/migrations/002_add_dlc_stock_epuise.sql"
|
||||||
|
run <<'SQL'
|
||||||
|
ALTER TABLE customer_orders ADD COLUMN IF NOT EXISTS notified_at TIMESTAMP;
|
||||||
|
ALTER TABLE customer_orders ADD COLUMN IF NOT EXISTS notified_comment TEXT;
|
||||||
|
ALTER TABLE suppliers ADD COLUMN IF NOT EXISTS codefou VARCHAR;
|
||||||
|
SQL
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
// Données du banc : 3 magasins, 5 comptes (un par profil à tester) et quelques
|
||||||
|
// lignes dans chaque table, datées par rapport au jour du passage pour que les
|
||||||
|
// alertes (DLC, retards, échéances) aient toujours quelque chose à montrer.
|
||||||
|
// Usage : DATABASE_URL=… SESSION_SECRET=… npx tsx scripts/bench/seed.ts
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
import pg from "pg";
|
||||||
|
import { scryptSync, createHash } from "crypto";
|
||||||
|
import { encryptSecret } from "./legacy";
|
||||||
|
import { BENCH_ACCOUNTS, BENCH_API_KEY, STUB_URL, STUB_SMTP_PORT } from "./config";
|
||||||
|
|
||||||
|
// Même format que l'ancien server/localAuth.ts : empreinte_hex.sel_hex, le sel hexadécimal
|
||||||
|
// servant tel quel de texte. Sel fixe pour un banc reproductible.
|
||||||
|
function hashPassword(password: string, salt: string): string {
|
||||||
|
return `${scryptSync(password, salt, 64).toString("hex")}.${salt}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Petit PNG 1×1 : un magasin a un logo, comme en production
|
||||||
|
const LOGO = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==";
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
|
||||||
|
await client.connect();
|
||||||
|
const q = (text: string, values: unknown[] = []) => client.query(text, values);
|
||||||
|
|
||||||
|
await q("BEGIN");
|
||||||
|
|
||||||
|
await q(`INSERT INTO nocodb_config (id, name, base_url, project_id, api_token, description, is_active, created_by, created_at, updated_at)
|
||||||
|
VALUES (1, 'NocoDB banc', $1, 'pbench', $2, 'Service simulé du banc', true, 'admin_local', CURRENT_DATE - 30 + time '08:00', CURRENT_DATE - 30 + time '08:00')`,
|
||||||
|
[`${STUB_URL}/nocodb`, encryptSecret("nocodb-token-banc")]);
|
||||||
|
|
||||||
|
await q(`INSERT INTO groups (id, name, color, nocodb_config_id, nocodb_table_name, nocodb_table_id, invoice_column_name, nocodb_bl_column_name,
|
||||||
|
nocodb_amount_column_name, nocodb_invoice_amount_ttc_column_name, nocodb_supplier_column_name, nocodb_due_date_column_name,
|
||||||
|
webhook_url, address, phone, logo, smtp_enabled, smtp_host, smtp_port, smtp_secure, smtp_user, smtp_password,
|
||||||
|
smtp_sender_email, smtp_sender_name, created_at, updated_at) VALUES
|
||||||
|
(1, 'Magasin Nancy', '#1976D2', 1, 'factures', 'tnancy', 'RefFacture', 'NumBL', 'MontantHT', 'MontantTTC', 'Fournisseur', 'Echeance',
|
||||||
|
$1, '12 rue des Dominicains, 54000 Nancy', '03 83 00 00 01', $2, true, '127.0.0.1', $3, false, 'nancy@banc.test', $4,
|
||||||
|
'nancy@banc.test', 'Magasin Nancy', CURRENT_DATE - 400 + time '08:00', CURRENT_DATE - 10 + time '08:00'),
|
||||||
|
(2, 'Magasin Metz', '#388E3C', 1, 'factures', 'tmetz', 'RefFacture', 'NumBL', 'MontantHT', 'MontantTTC', 'Fournisseur', 'Echeance',
|
||||||
|
NULL, NULL, NULL, NULL, false, NULL, NULL, false, NULL, NULL, NULL, NULL, CURRENT_DATE - 300 + time '08:00', CURRENT_DATE - 300 + time '08:00'),
|
||||||
|
(3, 'Magasin Épinal', '#F57C00', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL,
|
||||||
|
NULL, NULL, NULL, NULL, false, NULL, NULL, false, NULL, NULL, NULL, NULL, CURRENT_DATE - 200 + time '08:00', CURRENT_DATE - 200 + time '08:00')`,
|
||||||
|
[`${STUB_URL}/webhook/avoirs`, LOGO, STUB_SMTP_PORT, encryptSecret("smtp-secret-banc")]);
|
||||||
|
|
||||||
|
for (const account of BENCH_ACCOUNTS) {
|
||||||
|
await q(`INSERT INTO users (id, username, email, first_name, last_name, password, role, password_changed, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, true, CURRENT_DATE - 100 + time '08:00', CURRENT_DATE - 100 + time '08:00')`,
|
||||||
|
[account.id, account.username, `${account.username}@banc.test`, account.firstName, account.lastName,
|
||||||
|
hashPassword(account.password, createHash("md5").update(account.username).digest("hex")), account.role]);
|
||||||
|
for (const groupId of account.groupIds) {
|
||||||
|
await q(`INSERT INTO user_groups (user_id, group_id, created_at) VALUES ($1, $2, CURRENT_DATE - 100 + time '08:00')`, [account.id, groupId]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await q(`INSERT INTO suppliers (id, name, contact, phone, has_dlc, payment_method, automatic_reconciliation, requires_control, email, codefou, created_at, updated_at) VALUES
|
||||||
|
(1, 'Lactalis', 'M. Durand', '01 00 00 00 01', true, 'virement', false, false, 'commandes@lactalis.test', 'F001', CURRENT_DATE - 500 + time '08:00', CURRENT_DATE - 500 + time '08:00'),
|
||||||
|
(2, 'Bonduelle', 'Mme Petit', '01 00 00 00 02', true, 'prelevement', true, false, 'factures@bonduelle.test', 'F002', CURRENT_DATE - 490 + time '08:00', CURRENT_DATE - 490 + time '08:00'),
|
||||||
|
(3, 'Brico Pro', NULL, NULL, false, 'cheque', false, true, NULL, NULL, CURRENT_DATE - 480 + time '08:00', CURRENT_DATE - 480 + time '08:00'),
|
||||||
|
(4, 'Jardin & Co', 'M. Martin', '01 00 00 00 04', false, NULL, false, false, 'contact@jardin.test', 'F004', CURRENT_DATE - 470 + time '08:00', CURRENT_DATE - 470 + time '08:00')`);
|
||||||
|
|
||||||
|
await q(`INSERT INTO contacts (id, group_id, name, company, role, phone, email, notes, created_at, updated_at) VALUES
|
||||||
|
(1, 1, 'Paul Lefort', 'Lactalis', 'Commercial', '06 00 00 00 01', 'paul@lactalis.test', 'Passe le mardi', CURRENT_DATE - 50 + time '09:00', CURRENT_DATE - 50 + time '09:00'),
|
||||||
|
(2, 1, 'Agence intérim', NULL, NULL, '03 83 00 00 99', NULL, NULL, CURRENT_DATE - 40 + time '09:00', CURRENT_DATE - 40 + time '09:00'),
|
||||||
|
(3, 2, 'Julie Roux', 'Bonduelle', 'Livraisons', '06 00 00 00 03', NULL, NULL, CURRENT_DATE - 30 + time '09:00', CURRENT_DATE - 30 + time '09:00')`);
|
||||||
|
|
||||||
|
await q(`INSERT INTO orders (id, supplier_id, group_id, planned_date, quantity, unit, status, notes, created_by, created_at, updated_at) VALUES
|
||||||
|
(1, 1, 1, CURRENT_DATE - 20, 10, 'palettes', 'delivered', 'Commande mensuelle', 'admin_local', CURRENT_DATE - 25 + time '10:00', CURRENT_DATE - 20 + time '10:00'),
|
||||||
|
(2, 2, 1, CURRENT_DATE - 2, 5, 'colis', 'planned', NULL, 'bench_manager', CURRENT_DATE - 6 + time '10:00', CURRENT_DATE - 5 + time '10:00'),
|
||||||
|
(3, 3, 1, CURRENT_DATE + 3, 2, 'palettes', 'pending', 'Urgent', 'bench_manager', CURRENT_DATE - 1 + time '10:00', CURRENT_DATE - 1 + time '10:00'),
|
||||||
|
(4, 1, 2, CURRENT_DATE, 8, 'colis', 'pending', NULL, 'bench_directeur', CURRENT_DATE - 3 + time '10:00', CURRENT_DATE - 3 + time '10:00'),
|
||||||
|
(5, 4, 2, CURRENT_DATE - 40, 3, 'palettes', 'delivered', NULL, 'bench_directeur', CURRENT_DATE - 45 + time '10:00', CURRENT_DATE - 40 + time '10:00'),
|
||||||
|
(6, 2, 3, CURRENT_DATE + 10, 1, 'palettes', 'pending', NULL, 'admin_local', CURRENT_DATE + time '07:30', CURRENT_DATE + time '07:30'),
|
||||||
|
(7, 1, 1, CURRENT_DATE - 380, 4, 'palettes', 'delivered', 'Année précédente', 'admin_local', CURRENT_DATE - 385 + time '10:00', CURRENT_DATE - 380 + time '10:00')`);
|
||||||
|
|
||||||
|
await q(`INSERT INTO deliveries (id, order_id, supplier_id, group_id, scheduled_date, delivered_date, quantity, unit, status, notes, bl_number, bl_amount,
|
||||||
|
invoice_reference, invoice_amount, invoice_amount_ttc, due_date, reconciled, validated_at, control_validated, control_validated_by,
|
||||||
|
control_validated_at, created_by, created_at, updated_at) VALUES
|
||||||
|
(1, 1, 1, 1, CURRENT_DATE - 20, CURRENT_DATE - 20 + time '11:00', 10, 'palettes', 'delivered', NULL, 'BL-1001', 1250.50,
|
||||||
|
'FAC-2001', 1250.50, 1500.60, CURRENT_DATE + 10, true, CURRENT_DATE - 20 + time '11:05', false, NULL, NULL, 'admin_local', CURRENT_DATE - 21 + time '10:00', CURRENT_DATE - 19 + time '10:00'),
|
||||||
|
(2, NULL, 2, 1, CURRENT_DATE - 5, CURRENT_DATE - 5 + time '14:00', 6, 'colis', 'delivered', 'Carton abîmé', 'BL-1002', 310.00,
|
||||||
|
NULL, NULL, NULL, NULL, false, CURRENT_DATE - 5 + time '14:10', false, NULL, NULL, 'bench_manager', CURRENT_DATE - 6 + time '10:00', CURRENT_DATE - 5 + time '14:10'),
|
||||||
|
(3, 2, 2, 1, CURRENT_DATE - 2, NULL, 5, 'colis', 'planned', NULL, NULL, NULL,
|
||||||
|
NULL, NULL, NULL, NULL, false, NULL, false, NULL, NULL, 'bench_manager', CURRENT_DATE - 5 + time '10:00', CURRENT_DATE - 5 + time '10:00'),
|
||||||
|
(4, NULL, 3, 1, CURRENT_DATE - 1, CURRENT_DATE - 1 + time '09:30', 2, 'palettes', 'delivered', NULL, 'BL-1004', 980.00,
|
||||||
|
'FAC-2004', 975.00, 1170.00, CURRENT_DATE + 25, false, CURRENT_DATE - 1 + time '09:40', true, 'bench_manager', CURRENT_DATE - 1 + time '10:00', 'bench_manager', CURRENT_DATE - 2 + time '10:00', CURRENT_DATE - 1 + time '10:00'),
|
||||||
|
(5, 4, 1, 2, CURRENT_DATE, NULL, 8, 'colis', 'planned', NULL, NULL, NULL,
|
||||||
|
NULL, NULL, NULL, NULL, false, NULL, false, NULL, NULL, 'bench_directeur', CURRENT_DATE - 3 + time '10:00', CURRENT_DATE - 3 + time '10:00'),
|
||||||
|
(6, 5, 4, 2, CURRENT_DATE - 40, CURRENT_DATE - 40 + time '16:00', 3, 'palettes', 'delivered', NULL, 'BL-1006', 450.25,
|
||||||
|
'FAC-2006', 450.25, 540.30, CURRENT_DATE - 5, true, CURRENT_DATE - 40 + time '16:10', false, NULL, NULL, 'bench_directeur', CURRENT_DATE - 41 + time '10:00', CURRENT_DATE - 39 + time '10:00'),
|
||||||
|
(7, NULL, 1, 3, CURRENT_DATE + 2, NULL, 1, 'palettes', 'planned', NULL, NULL, NULL,
|
||||||
|
NULL, NULL, NULL, NULL, false, NULL, false, NULL, NULL, 'admin_local', CURRENT_DATE + time '07:00', CURRENT_DATE + time '07:00'),
|
||||||
|
(8, 7, 1, 1, CURRENT_DATE - 380, CURRENT_DATE - 380 + time '11:00', 4, 'palettes', 'delivered', NULL, 'BL-0900', 800.00,
|
||||||
|
'FAC-1900', 800.00, 960.00, CURRENT_DATE - 350, true, CURRENT_DATE - 380 + time '11:05', false, NULL, NULL, 'admin_local', CURRENT_DATE - 381 + time '10:00', CURRENT_DATE - 379 + time '10:00')`);
|
||||||
|
|
||||||
|
await q(`INSERT INTO reconciliation_comments (id, delivery_id, group_id, content, type, author_id, created_at, updated_at) VALUES
|
||||||
|
(1, 2, 1, 'Facture non reçue, relancer le fournisseur', 'warning', 'bench_manager', CURRENT_DATE - 4 + time '09:00', CURRENT_DATE - 4 + time '09:00'),
|
||||||
|
(2, 4, 1, 'Écart de 5 € sur le BL', 'info', 'admin_local', CURRENT_DATE - 1 + time '15:00', CURRENT_DATE - 1 + time '15:00')`);
|
||||||
|
|
||||||
|
await q(`INSERT INTO invoice_verification_cache (id, cache_key, group_id, invoice_reference, supplier_name, invoice_amount, invoice_amount_ttc, due_date,
|
||||||
|
exists, match_type, error_message, cache_hit, api_call_time, is_reconciled, expires_at, created_at, updated_at) VALUES
|
||||||
|
(1, '1_fac-2001', 1, 'FAC-2001', 'Lactalis', 1250.50, 1500.60, CURRENT_DATE + 10, true, 'invoice_reference', NULL, false, 120, true,
|
||||||
|
CURRENT_DATE + 18000, CURRENT_DATE - 20 + time '11:10', CURRENT_DATE - 20 + time '11:10'),
|
||||||
|
(2, '1_fac-2004', 1, 'FAC-2004', 'Brico Pro', 975.00, 1170.00, CURRENT_DATE + 25, true, 'invoice_reference', NULL, false, 95, false,
|
||||||
|
CURRENT_DATE + 1, CURRENT_DATE - 1 + time '10:30', CURRENT_DATE - 1 + time '10:30')`);
|
||||||
|
|
||||||
|
await q(`INSERT INTO publicities (id, pub_number, designation, start_date, end_date, year, created_by, created_at, updated_at) VALUES
|
||||||
|
(1, 'PUB-01', 'Foire aux vins', CURRENT_DATE - 10, CURRENT_DATE + 5, EXTRACT(YEAR FROM CURRENT_DATE)::int, 'admin_local', CURRENT_DATE - 60 + time '09:00', CURRENT_DATE - 60 + time '09:00'),
|
||||||
|
(2, 'PUB-02', 'Spécial jardin', CURRENT_DATE + 20, CURRENT_DATE + 34, EXTRACT(YEAR FROM CURRENT_DATE)::int, 'admin_local', CURRENT_DATE - 50 + time '09:00', CURRENT_DATE - 50 + time '09:00'),
|
||||||
|
(3, 'PUB-99', 'Rentrée', CURRENT_DATE - 400, CURRENT_DATE - 386, EXTRACT(YEAR FROM CURRENT_DATE)::int - 1, 'admin_local', CURRENT_DATE - 420 + time '09:00', CURRENT_DATE - 420 + time '09:00')`);
|
||||||
|
await q(`INSERT INTO publicity_participations (publicity_id, group_id, created_at) VALUES
|
||||||
|
(1, 1, CURRENT_DATE - 60 + time '09:00'), (1, 2, CURRENT_DATE - 60 + time '09:00'), (2, 1, CURRENT_DATE - 50 + time '09:00'), (3, 2, CURRENT_DATE - 420 + time '09:00')`);
|
||||||
|
|
||||||
|
await q(`INSERT INTO customer_orders (id, order_taker, customer_name, customer_phone, customer_email, product_designation, product_reference, gencode,
|
||||||
|
quantity, supplier_id, status, deposit, is_promotional_price, customer_notified, notes, group_id, created_by, created_at, updated_at,
|
||||||
|
notified_at, notified_comment) VALUES
|
||||||
|
(1, 'Sophie', 'Mme Bernard', '06 11 11 11 11', 'bernard@client.test', 'Tondeuse électrique', 'TD-450', '3245678901234', 1, 4,
|
||||||
|
'En attente de Commande', 50.00, false, false, NULL, 1, 'bench_employee', CURRENT_DATE - 3 + time '15:00', CURRENT_DATE - 3 + time '15:00', NULL, NULL),
|
||||||
|
(2, 'Marc', 'M. Laurent', '06 22 22 22 22', NULL, 'Perceuse sans fil', 'PS-18', '3245678901241', 2, 3,
|
||||||
|
'Commande passée', 0.00, true, false, 'Prix promo', 1, 'bench_manager', CURRENT_DATE - 8 + time '11:00', CURRENT_DATE - 6 + time '11:00', NULL, NULL),
|
||||||
|
(3, 'Sophie', 'Mme Garcia', '06 33 33 33 33', NULL, 'Fromage affiné', NULL, '3245678901258', 1, 1,
|
||||||
|
'Disponible', 0.00, false, false, NULL, 1, 'bench_employee', CURRENT_DATE - 10 + time '10:00', CURRENT_DATE - 1 + time '10:00', NULL, NULL),
|
||||||
|
(4, 'Léa', 'M. Thomas', '06 44 44 44 44', NULL, 'Graines de gazon 5 kg', 'GZ-5', '3245678901265', 3, 4,
|
||||||
|
'Disponible', 10.00, false, true, NULL, 2, 'bench_directeur', CURRENT_DATE - 12 + time '10:00', CURRENT_DATE - 2 + time '10:00', CURRENT_DATE - 2 + time '11:00', 'Message laissé'),
|
||||||
|
(5, 'Léa', 'Mme Petit', '06 55 55 55 55', NULL, 'Arrosoir', NULL, '3245678901272', 1, 4,
|
||||||
|
'Retiré', 0.00, false, true, NULL, 2, 'bench_directeur', CURRENT_DATE - 30 + time '10:00', CURRENT_DATE - 20 + time '10:00', CURRENT_DATE - 25 + time '10:00', NULL)`);
|
||||||
|
|
||||||
|
await q(`INSERT INTO dlc_products (id, name, product_name, gencode, supplier_id, group_id, expiry_date, date_type, quantity, unit, location, alert_threshold,
|
||||||
|
status, stock_epuise, stock_epuise_by, stock_epuise_at, processed_at, processed_by, processed_until_expiry, notes, created_by,
|
||||||
|
validated_by, validated_at, created_at, updated_at) VALUES
|
||||||
|
(1, 'Yaourt nature', 'Yaourt nature x4', '3000000000011', 1, 1, CURRENT_DATE - 2, 'dlc', 12, 'unité', 'Rayon frais', 15,
|
||||||
|
'en_cours', false, NULL, NULL, NULL, NULL, false, NULL, 'bench_employee', NULL, NULL, CURRENT_DATE - 9 + time '08:30', CURRENT_DATE - 9 + time '08:30'),
|
||||||
|
(2, 'Camembert', 'Camembert 250 g', '3000000000028', 1, 1, CURRENT_DATE + 3, 'dlc', 6, 'unité', 'Rayon frais', 15,
|
||||||
|
'en_cours', false, NULL, NULL, NULL, NULL, false, 'Promo possible', 'bench_manager', NULL, NULL, CURRENT_DATE - 7 + time '08:30', CURRENT_DATE - 7 + time '08:30'),
|
||||||
|
(3, 'Haricots verts', 'Haricots verts 400 g', '3000000000035', 2, 1, CURRENT_DATE + 60, 'ddm', 24, 'unité', 'Réserve', 15,
|
||||||
|
'valides', false, NULL, NULL, NULL, NULL, false, NULL, 'bench_manager', 'bench_manager', CURRENT_DATE - 6 + time '09:00', CURRENT_DATE - 6 + time '08:30', CURRENT_DATE - 6 + time '09:00'),
|
||||||
|
(4, 'Crème fraîche', 'Crème fraîche 20 cl', '3000000000042', 1, 1, CURRENT_DATE + 1, 'dlc', 4, 'unité', 'Rayon frais', 15,
|
||||||
|
'en_cours', true, 'bench_employee', CURRENT_DATE + time '08:00', NULL, NULL, false, NULL, 'bench_employee', NULL, NULL, CURRENT_DATE - 5 + time '08:30', CURRENT_DATE + time '08:00'),
|
||||||
|
(5, 'Beurre doux', 'Beurre doux 250 g', '3000000000059', 1, 1, CURRENT_DATE + 2, 'dlc', 10, 'unité', 'Rayon frais', 15,
|
||||||
|
'en_cours', false, NULL, NULL, CURRENT_DATE - 1 + time '17:00', 'bench_manager', true, NULL, 'bench_manager', NULL, NULL, CURRENT_DATE - 4 + time '08:30', CURRENT_DATE - 1 + time '17:00'),
|
||||||
|
(6, 'Petits pois', 'Petits pois 800 g', '3000000000066', 2, 2, CURRENT_DATE + 10, 'dlc', 30, 'unité', 'Magasin', 15,
|
||||||
|
'en_cours', false, NULL, NULL, NULL, NULL, false, NULL, 'bench_directeur', NULL, NULL, CURRENT_DATE - 3 + time '08:30', CURRENT_DATE - 3 + time '08:30')`);
|
||||||
|
|
||||||
|
await q(`INSERT INTO tasks (id, title, description, start_date, due_date, priority, status, assigned_to, created_by, group_id, completed_at, completed_by, created_at, updated_at) VALUES
|
||||||
|
(1, 'Inventaire rayon frais', 'Compter les yaourts', CURRENT_DATE - 2 + time '08:00', CURRENT_DATE - 1, 'high', 'pending', 'Équipe frais', 'bench_manager', 1, NULL, NULL, CURRENT_DATE - 3 + time '08:00', CURRENT_DATE - 3 + time '08:00'),
|
||||||
|
(2, 'Mise en rayon promo', NULL, NULL, CURRENT_DATE + 2, 'medium', 'pending', 'Sophie', 'bench_manager', 1, NULL, NULL, CURRENT_DATE - 1 + time '08:00', CURRENT_DATE - 1 + time '08:00'),
|
||||||
|
(3, 'Nettoyage réserve', NULL, CURRENT_DATE + 5 + time '08:00', CURRENT_DATE + 7, 'low', 'pending', 'Marc', 'bench_directeur', 1, NULL, NULL, CURRENT_DATE - 1 + time '09:00', CURRENT_DATE - 1 + time '09:00'),
|
||||||
|
(4, 'Relevé températures', NULL, NULL, CURRENT_DATE - 3, 'medium', 'completed', 'Léa', 'bench_directeur', 2, CURRENT_DATE - 3 + time '18:00', 'bench_directeur', CURRENT_DATE - 5 + time '08:00', CURRENT_DATE - 3 + time '18:00'),
|
||||||
|
(5, 'Affichage prix', NULL, NULL, CURRENT_DATE, 'high', 'pending', 'Léa', 'bench_directeur', 2, NULL, NULL, CURRENT_DATE - 2 + time '08:00', CURRENT_DATE - 2 + time '08:00')`);
|
||||||
|
|
||||||
|
await q(`INSERT INTO dashboard_messages (id, title, content, type, store_id, created_by, created_at) VALUES
|
||||||
|
(1, 'Inventaire annuel', 'Inventaire le 30 du mois', 'info', NULL, 'admin_local', CURRENT_DATE - 1 + time '08:00'),
|
||||||
|
(2, 'Panne caisse 3', 'Caisse 3 hors service', 'warning', 1, 'bench_manager', CURRENT_DATE + time '07:45'),
|
||||||
|
(3, 'Ancienne information', 'Texte ancien', 'info', 2, 'admin_local', CURRENT_DATE - 20 + time '08:00')`);
|
||||||
|
|
||||||
|
await q(`INSERT INTO avoirs (id, supplier_id, group_id, invoice_reference, amount, comment, commercial_processed, status, webhook_sent, nocodb_verified,
|
||||||
|
nocodb_verified_at, processed_at, created_by, created_at, updated_at) VALUES
|
||||||
|
(1, 1, 1, 'AV-3001', 45.90, 'Produits périmés à la livraison', false, 'En attente de demande', false, false, NULL, NULL, 'bench_manager', CURRENT_DATE - 4 + time '10:00', CURRENT_DATE - 4 + time '10:00'),
|
||||||
|
(2, 3, 1, 'AV-3002', 120.00, NULL, true, 'Demandé', true, false, NULL, CURRENT_DATE - 2 + time '10:00', 'admin_local', CURRENT_DATE - 6 + time '10:00', CURRENT_DATE - 2 + time '10:00'),
|
||||||
|
(3, 4, 2, NULL, 15.00, 'Casse', true, 'Reçu', true, true, CURRENT_DATE - 1 + time '12:00', CURRENT_DATE - 1 + time '12:00', 'bench_directeur', CURRENT_DATE - 9 + time '10:00', CURRENT_DATE - 1 + time '12:00')`);
|
||||||
|
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
await q(`INSERT INTO sav_tickets (id, ticket_number, supplier_id, group_id, product_gencode, product_reference, product_designation, problem_type,
|
||||||
|
problem_description, resolution_description, status, priority, client_name, client_phone, created_by, created_at, updated_at, resolved_at, closed_at) VALUES
|
||||||
|
(1, $1, 3, 1, '3245678901241', 'PS-18', 'Perceuse sans fil', 'defectueux', 'Ne démarre pas', NULL, 'nouveau', 'haute', 'M. Laurent', '06 22 22 22 22', 'bench_employee', CURRENT_DATE - 2 + time '16:00', CURRENT_DATE - 2 + time '16:00', NULL, NULL),
|
||||||
|
(2, $2, 4, 1, '3245678901234', 'TD-450', 'Tondeuse électrique', 'pieces_manquantes', 'Manque le bac', NULL, 'attente_pieces', 'normale', NULL, NULL, 'bench_manager', CURRENT_DATE - 15 + time '10:00', CURRENT_DATE - 5 + time '10:00', NULL, NULL),
|
||||||
|
(3, $3, 4, 2, '3245678901265', 'GZ-5', 'Graines de gazon 5 kg', 'non_conforme', 'Sac déchiré', 'Échangé', 'resolu', 'faible', 'M. Thomas', NULL, 'bench_directeur', CURRENT_DATE - 25 + time '10:00', CURRENT_DATE - 20 + time '10:00', CURRENT_DATE - 20 + time '10:00', NULL)`,
|
||||||
|
[`SAV-${year}-0001`, `SAV-${year}-0002`, `SAV-${year}-0003`]);
|
||||||
|
|
||||||
|
await q(`INSERT INTO supplier_mail_logs (id, delivery_id, group_id, supplier_id, supplier_name, sent_to, subject, status, error_message, message_id, sent_by, sent_by_name, created_at) VALUES
|
||||||
|
(1, 2, 1, 2, 'Bonduelle', 'factures@bonduelle.test', 'Demande de facture BL-1002', 'sent', NULL, '<banc-1@banc.test>', 'bench_manager', 'Manon Manager', CURRENT_DATE - 3 + time '09:00')`);
|
||||||
|
|
||||||
|
await q(`INSERT INTO weather_settings (id, api_key, location, is_active, created_at, updated_at) VALUES
|
||||||
|
(1, 'cle-meteo-banc', 'Nancy, France', true, CURRENT_DATE - 100 + time '08:00', CURRENT_DATE - 100 + time '08:00')`);
|
||||||
|
|
||||||
|
await q(`INSERT INTO utilities (id, sales_analysis_url, automatic_backups_enabled, created_at, updated_at) VALUES
|
||||||
|
(1, 'https://analyse.banc.test/rapport', false, CURRENT_DATE - 100 + time '08:00', CURRENT_DATE - 100 + time '08:00')`);
|
||||||
|
|
||||||
|
await q(`INSERT INTO webhook_bap_config (id, name, webhook_url, description, is_active, created_at, updated_at) VALUES
|
||||||
|
(1, 'Configuration BAP', $1, 'Webhook simulé du banc', true, CURRENT_DATE - 100 + time '08:00', CURRENT_DATE - 100 + time '08:00')`,
|
||||||
|
[`${STUB_URL}/webhook/bap`]);
|
||||||
|
|
||||||
|
await q(`INSERT INTO database_backups (id, filename, description, size, created_at, created_by, tables_count, status, backup_type) VALUES
|
||||||
|
('banc-sauvegarde-1', 'backup_banc_1.sql', 'Sauvegarde du banc', 2048, CURRENT_DATE - 1 + time '02:00', 'system', 26, 'completed', 'automatic')`);
|
||||||
|
|
||||||
|
await q(`INSERT INTO external_api_keys (id, name, key_prefix, key_hash, created_by, created_at, last_used_at, revoked_at) VALUES
|
||||||
|
(1, 'Outil de rapprochement', $1, $2, 'admin_local', CURRENT_DATE - 30 + time '08:00', NULL, NULL),
|
||||||
|
(2, 'Ancienne clé', 'lf_oldkey', $3, 'admin_local', CURRENT_DATE - 90 + time '08:00', NULL, CURRENT_DATE - 60 + time '08:00')`,
|
||||||
|
[BENCH_API_KEY.slice(0, 10), createHash("sha256").update(BENCH_API_KEY).digest("hex"), createHash("sha256").update("lf_cle_revoquee").digest("hex")]);
|
||||||
|
|
||||||
|
// Données complémentaires de chaque lot (seeds/*.ts, voir seeds/README.md)
|
||||||
|
const seedsDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "seeds");
|
||||||
|
for (const file of fs.readdirSync(seedsDir).filter((f) => f.endsWith(".ts")).sort()) {
|
||||||
|
const extra = await import(path.join(seedsDir, file));
|
||||||
|
await extra.seed(q);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Les séquences reprennent après les identifiants fixés ci-dessus
|
||||||
|
const { rows } = await q(`SELECT table_name, column_name FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public' AND column_default LIKE 'nextval%'`);
|
||||||
|
for (const row of rows) {
|
||||||
|
await q(`SELECT setval(pg_get_serial_sequence($1, $2), COALESCE((SELECT MAX(${row.column_name}) FROM ${row.table_name}), 0) + 1, false)`,
|
||||||
|
[row.table_name, row.column_name]);
|
||||||
|
}
|
||||||
|
|
||||||
|
await q("COMMIT");
|
||||||
|
await client.end();
|
||||||
|
console.log("Données du banc insérées");
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => { console.error(error); process.exit(1); });
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
Données complémentaires du banc, une par lot : chaque fichier `*.ts` exporte
|
||||||
|
`seed(q)` (voir `seed.ts`), appelée après les données de base, par ordre
|
||||||
|
alphabétique de nom de fichier. Identifiants à partir de 100 pour ne pas
|
||||||
|
heurter ceux de `seed.ts` ni ceux d'un autre fichier : choisir une centaine
|
||||||
|
libre (lot 2 : 200…, lot 3 : 300…, lot 4 : 400…, lot 5 : 500…).
|
||||||
|
|
||||||
|
Les fichiers de `sauvegardes/` (sauvegardes déclarées dans `database_backups`)
|
||||||
|
sont copiés par `compare.ts` dans le dossier de sauvegardes de chaque serveur.
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
// Données du lot 2 (Informations et planning), identifiants à partir de 200 :
|
||||||
|
// - informations au-delà de la limite de 5, par magasin et globales, avec des
|
||||||
|
// auteurs de chaque forme (prénom et nom, nom d'affichage seul, prénom seul,
|
||||||
|
// compte disparu) ;
|
||||||
|
// - publicités dont les N° testent le tri numérique (« 12 », « 3 », « 3-bis »)
|
||||||
|
// et des participations du magasin d'Épinal ;
|
||||||
|
// - une commande livrée en retard par magasin hors Nancy, pour des délais moyens
|
||||||
|
// non nuls dans les statistiques ;
|
||||||
|
// - la météo en cache pour aujourd'hui et pour la date de l'an dernier calculée
|
||||||
|
// par le serveur Node : /api/weather/current ne sollicite jamais Visual
|
||||||
|
// Crossing pendant le banc (fenêtre de 9 jours, au cas où le banc tourne
|
||||||
|
// quelques jours après setup.sh).
|
||||||
|
import { previousYearDate } from "../legacy";
|
||||||
|
|
||||||
|
type Query = (text: string, values?: unknown[]) => Promise<unknown>;
|
||||||
|
|
||||||
|
function isoDay(offset: number): string {
|
||||||
|
const date = new Date();
|
||||||
|
date.setUTCDate(date.getUTCDate() + offset);
|
||||||
|
return date.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function seed(q: Query) {
|
||||||
|
// Auteurs d'informations sans magasin (connexion impossible : empreinte factice)
|
||||||
|
await q(`INSERT INTO users (id, username, email, name, first_name, last_name, password, role, password_changed, created_at, updated_at) VALUES
|
||||||
|
('bench_lot2_redaction', 'redaction', 'redaction@banc.test', 'Équipe Rédaction Nancy', NULL, NULL, 'x', 'employee', true, CURRENT_DATE - 90 + time '08:00', CURRENT_DATE - 90 + time '08:00'),
|
||||||
|
('bench_lot2_prenom', 'prenom-seul', NULL, NULL, 'Paulette', NULL, 'x', 'employee', true, CURRENT_DATE - 90 + time '08:00', CURRENT_DATE - 90 + time '08:00')`);
|
||||||
|
|
||||||
|
await q(`INSERT INTO dashboard_messages (id, title, content, type, store_id, created_by, created_at) VALUES
|
||||||
|
(200, 'Fermeture exceptionnelle', 'Fermeture à 18 h samedi', 'error', 1, 'manager', CURRENT_DATE - 2 + time '09:00'),
|
||||||
|
(201, 'Nouvelle procédure', 'Procédure de réception mise à jour', 'success', NULL, 'admin', CURRENT_DATE - 3 + time '10:00'),
|
||||||
|
(202, 'Réunion Metz', 'Réunion d''équipe jeudi', 'info', 2, 'directeur', CURRENT_DATE - 1 + time '14:00'),
|
||||||
|
(203, 'Message de la rédaction', 'Nouvelle charte affichage', 'warning', 3, 'redaction', CURRENT_DATE - 5 + time '11:00'),
|
||||||
|
(204, 'Prénom seul', 'Auteur sans nom de famille', 'info', NULL, 'prenom-seul', CURRENT_DATE - 4 + time '16:30'),
|
||||||
|
(205, 'Auteur supprimé', 'Compte disparu depuis', 'info', 1, 'ancien-compte', CURRENT_DATE - 6 + time '08:15')`);
|
||||||
|
|
||||||
|
const year = new Date().getUTCFullYear();
|
||||||
|
await q(`INSERT INTO publicities (id, pub_number, designation, start_date, end_date, year, created_by, created_at, updated_at) VALUES
|
||||||
|
(200, '12', 'Promo hiver', CURRENT_DATE + 40, CURRENT_DATE + 50, $1, 'admin_local', CURRENT_DATE - 30 + time '09:00', CURRENT_DATE - 30 + time '09:00'),
|
||||||
|
(201, '3', 'Semaine du blanc', CURRENT_DATE - 3, CURRENT_DATE + 4, $1, 'admin_local', CURRENT_DATE - 40 + time '09:00', CURRENT_DATE - 35 + time '09:00'),
|
||||||
|
(202, '3-bis', 'Semaine du blanc (suite)', CURRENT_DATE + 5, CURRENT_DATE + 9, $1, 'admin_local', CURRENT_DATE - 40 + time '09:30', CURRENT_DATE - 40 + time '09:30'),
|
||||||
|
(203, 'PUB-03', 'Catalogue de l''an prochain', CURRENT_DATE + 200, CURRENT_DATE + 214, $2, 'admin_local', CURRENT_DATE - 10 + time '09:00', CURRENT_DATE - 10 + time '09:00')`,
|
||||||
|
[year, year + 1]);
|
||||||
|
await q(`INSERT INTO publicity_participations (publicity_id, group_id, created_at) VALUES
|
||||||
|
(200, 3, CURRENT_DATE - 30 + time '09:00'), (201, 1, CURRENT_DATE - 40 + time '09:00'), (201, 3, CURRENT_DATE - 40 + time '09:00'),
|
||||||
|
(203, 2, CURRENT_DATE - 10 + time '09:00')`);
|
||||||
|
|
||||||
|
await q(`INSERT INTO orders (id, supplier_id, group_id, planned_date, quantity, unit, status, notes, created_by, created_at, updated_at) VALUES
|
||||||
|
(200, 2, 2, CURRENT_DATE - 12, 7, 'palettes', 'delivered', NULL, 'bench_directeur', CURRENT_DATE - 14 + time '10:00', CURRENT_DATE - 9 + time '15:10'),
|
||||||
|
(201, 4, 3, CURRENT_DATE - 15, 2, 'colis', 'delivered', NULL, 'admin_local', CURRENT_DATE - 16 + time '10:00', CURRENT_DATE - 13 + time '10:10')`);
|
||||||
|
await q(`INSERT INTO deliveries (id, order_id, supplier_id, group_id, scheduled_date, delivered_date, quantity, unit, status, notes, bl_number, bl_amount,
|
||||||
|
invoice_reference, invoice_amount, invoice_amount_ttc, due_date, reconciled, validated_at, control_validated, created_by, created_at, updated_at) VALUES
|
||||||
|
(200, 200, 2, 2, CURRENT_DATE - 12, CURRENT_DATE - 9 + time '15:00', 7, 'palettes', 'delivered', NULL, 'BL-2200', 640.40,
|
||||||
|
'FAC-2200', 640.40, 768.48, CURRENT_DATE + 20, false, CURRENT_DATE - 9 + time '15:10', false, 'bench_directeur', CURRENT_DATE - 14 + time '10:00', CURRENT_DATE - 9 + time '15:10'),
|
||||||
|
(201, 201, 4, 3, CURRENT_DATE - 15, CURRENT_DATE - 13 + time '10:00', 2, 'colis', 'delivered', NULL, 'BL-2201', NULL,
|
||||||
|
NULL, NULL, NULL, NULL, false, CURRENT_DATE - 13 + time '10:10', false, 'admin_local', CURRENT_DATE - 16 + time '10:00', CURRENT_DATE - 13 + time '10:10')`);
|
||||||
|
|
||||||
|
// Météo en cache : même date de l'an dernier que GET /api/weather/current
|
||||||
|
const conditions = [
|
||||||
|
["partly-cloudy-day", "Partiellement nuageux"],
|
||||||
|
["rain", "Pluie légère"],
|
||||||
|
["clear-day", "Dégagé"],
|
||||||
|
["fog", "Brouillard"],
|
||||||
|
["icone-inconnue", "Variable"],
|
||||||
|
];
|
||||||
|
const rows: unknown[][] = [];
|
||||||
|
const previousDates = new Set<string>();
|
||||||
|
for (let offset = -1; offset <= 7; offset++) {
|
||||||
|
const day = isoDay(offset);
|
||||||
|
const [icon, label] = conditions[(offset + 1) % conditions.length];
|
||||||
|
rows.push([day, "Nancy, France", (14.3 + offset).toFixed(1), (6 + offset / 2).toString(), icon, label, true]);
|
||||||
|
const previous = previousYearDate(new Date(`${day}T12:00:00Z`));
|
||||||
|
if (!previousDates.has(previous)) {
|
||||||
|
previousDates.add(previous);
|
||||||
|
const [oldIcon, oldLabel] = conditions[(offset + 3) % conditions.length];
|
||||||
|
rows.push([previous, "Nancy, France", (12 + offset).toString(), (3.5 - offset).toFixed(2), oldIcon, oldLabel, false]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let id = 200;
|
||||||
|
for (const row of rows) {
|
||||||
|
await q(`INSERT INTO weather_data (id, date, location, temp_max, temp_min, icon, conditions, is_current_year, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, CURRENT_DATE - 1 + time '06:00', CURRENT_DATE - 1 + time '06:00')`, [id++, ...row]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
// Lot 3 « Au quotidien » : tâches, DLC, commandes clients et SAV (identifiants 300…).
|
||||||
|
// Complète seed.ts pour exercer ce que ses lignes ne couvrent pas : une ligne du
|
||||||
|
// magasin d'Épinal (aucun compte du banc n'y est affecté, sauf l'administrateur),
|
||||||
|
// les bornes de date des filtres (aujourd'hui, J+15, J+16) et des tâches
|
||||||
|
// programmées plus tard dans la journée.
|
||||||
|
type Query = (text: string, values?: unknown[]) => Promise<unknown>;
|
||||||
|
|
||||||
|
export async function seed(q: Query) {
|
||||||
|
await q(`INSERT INTO tasks (id, title, description, start_date, due_date, priority, status, assigned_to, created_by, group_id, completed_at, completed_by, created_at, updated_at) VALUES
|
||||||
|
(300, 'Inventaire Épinal', 'Magasin sans compte du banc', NULL, CURRENT_DATE + 4, 'medium', 'pending', 'Équipe Épinal', 'admin_local', 3, NULL, NULL, CURRENT_DATE - 2 + time '09:00', CURRENT_DATE - 2 + time '09:00'),
|
||||||
|
(301, 'Réception livraison', NULL, CURRENT_DATE + time '00:00', CURRENT_DATE, 'high', 'pending', 'Marc', 'bench_manager', 1, NULL, NULL, CURRENT_DATE - 1 + time '07:00', CURRENT_DATE - 1 + time '07:00'),
|
||||||
|
(302, 'Préparer la vitrine', NULL, CURRENT_DATE + time '10:00', CURRENT_DATE + 1, 'low', 'pending', 'Sophie', 'bench_directeur', 1, NULL, NULL, CURRENT_DATE - 1 + time '06:00', CURRENT_DATE - 1 + time '06:00'),
|
||||||
|
(303, 'Commande ancienne', NULL, CURRENT_DATE - 10 + time '08:00', CURRENT_DATE - 9, 'medium', 'completed', 'Léa', 'bench_directeur', 2, CURRENT_DATE - 9 + time '17:00', 'bench_directeur', CURRENT_DATE - 12 + time '08:00', CURRENT_DATE - 9 + time '17:00')`);
|
||||||
|
|
||||||
|
await q(`INSERT INTO dlc_products (id, name, product_name, gencode, supplier_id, group_id, expiry_date, date_type, quantity, unit, location, alert_threshold,
|
||||||
|
status, stock_epuise, stock_epuise_by, stock_epuise_at, processed_at, processed_by, processed_until_expiry, notes, created_by,
|
||||||
|
validated_by, validated_at, created_at, updated_at) VALUES
|
||||||
|
(300, 'Saucisson', 'Saucisson sec', '3000000000301', 1, 3, CURRENT_DATE + 5, 'dlc', 8, 'unité', 'Magasin', 15,
|
||||||
|
'en_cours', false, NULL, NULL, NULL, NULL, false, NULL, 'admin_local', NULL, NULL, CURRENT_DATE - 2 + time '08:30', CURRENT_DATE - 2 + time '08:30'),
|
||||||
|
(301, 'Lait demi-écrémé', 'Lait demi-écrémé 1 L', '3000000000318', 1, 1, CURRENT_DATE + 30, 'dlc', 20, 'unité', 'Réserve', 15,
|
||||||
|
'en_cours', false, NULL, NULL, NULL, NULL, false, NULL, 'bench_employee', NULL, NULL, CURRENT_DATE - 1 + time '08:30', CURRENT_DATE - 1 + time '08:30'),
|
||||||
|
(302, 'Compote', 'Compote pomme x4', '3000000000325', 2, 1, CURRENT_DATE, 'ddm', 5, 'unité', 'Magasin', 15,
|
||||||
|
'en_cours', false, NULL, NULL, NULL, NULL, false, NULL, 'bench_manager', NULL, NULL, CURRENT_DATE - 8 + time '08:30', CURRENT_DATE - 8 + time '08:30'),
|
||||||
|
(303, 'Jus d''orange', NULL, NULL, 2, 1, CURRENT_DATE + 15, 'dluo', 3, 'unité', 'Magasin', 15,
|
||||||
|
'en_cours', NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'bench_manager', NULL, NULL, CURRENT_DATE - 6 + time '09:30', CURRENT_DATE - 6 + time '09:30'),
|
||||||
|
(304, 'Fromage blanc', 'Fromage blanc 500 g', '3000000000349', 1, 2, CURRENT_DATE + 16, 'dlc', 7, 'unité', 'Rayon frais', 15,
|
||||||
|
'en_cours', false, NULL, NULL, NULL, NULL, false, NULL, 'bench_directeur', NULL, NULL, CURRENT_DATE - 4 + time '10:30', CURRENT_DATE - 4 + time '10:30'),
|
||||||
|
(305, 'Pâté de campagne', 'Pâté de campagne 180 g', '3000000000356', 1, 1, CURRENT_DATE - 5, 'dlc', 2, 'unité', 'Rayon frais', 15,
|
||||||
|
'valides', false, NULL, NULL, NULL, NULL, false, 'Retiré du rayon', 'bench_employee', 'bench_manager', CURRENT_DATE - 5 + time '08:00', CURRENT_DATE - 12 + time '08:30', CURRENT_DATE - 5 + time '08:00')`);
|
||||||
|
|
||||||
|
await q(`INSERT INTO customer_orders (id, order_taker, customer_name, customer_phone, customer_email, product_designation, product_reference, gencode,
|
||||||
|
quantity, supplier_id, status, deposit, is_promotional_price, customer_notified, notes, group_id, created_by, created_at, updated_at,
|
||||||
|
notified_at, notified_comment) VALUES
|
||||||
|
(300, 'Hugo', 'M. Colin', '06 66 66 66 66', NULL, 'Tronçonneuse', 'TR-35', '3245678901300', 1, 3,
|
||||||
|
'Arrivé', 20.00, false, false, NULL, 3, 'admin_local', CURRENT_DATE - 4 + time '12:00', CURRENT_DATE - 1 + time '12:00', NULL, NULL),
|
||||||
|
(301, 'Sophie', 'Mme Robert', '06 77 77 77 77', 'robert@client.test', 'Plateau de fromages', NULL, '3245678901317', 1, 1,
|
||||||
|
'prêt', 0.00, false, false, 'Pour samedi', 1, 'bench_employee', CURRENT_DATE - 2 + time '09:00', CURRENT_DATE + time '06:00', NULL, NULL),
|
||||||
|
(302, 'Léa', 'M. Simon', '06 88 88 88 88', NULL, 'Sécateur', 'SC-20', '3245678901324', 2, 4,
|
||||||
|
'Disponible', 0.00, false, NULL, NULL, 2, 'bench_directeur', CURRENT_DATE - 7 + time '09:00', CURRENT_DATE - 3 + time '09:00', NULL, NULL),
|
||||||
|
(303, 'Léa', 'Mme Michel', '06 99 99 99 99', NULL, 'Terreau 40 L', NULL, '3245678901331', 4, 4,
|
||||||
|
'Prêt', 5.50, true, false, NULL, 2, 'bench_directeur', CURRENT_DATE - 9 + time '09:00', CURRENT_DATE - 2 + time '09:00', NULL, NULL)`);
|
||||||
|
|
||||||
|
// 301 : créé l'an dernier (hors du compte qui numérote les nouveaux tickets),
|
||||||
|
// par un compte supprimé depuis (créateur absent de la jointure)
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
await q(`INSERT INTO sav_tickets (id, ticket_number, supplier_id, group_id, product_gencode, product_reference, product_designation, problem_type,
|
||||||
|
problem_description, resolution_description, status, priority, client_name, client_phone, created_by, created_at, updated_at, resolved_at, closed_at) VALUES
|
||||||
|
(300, $1, 2, 3, '3245678901300', 'TR-35', 'Tronçonneuse', 'autre', 'Chaîne bloquée', NULL, 'en_cours', 'critique', 'M. Colin', '06 66 66 66 66', 'admin_local', CURRENT_DATE - 3 + time '11:00', CURRENT_DATE - 1 + time '11:00', NULL, NULL),
|
||||||
|
(301, $2, 1, 1, '3000000000011', NULL, 'Yaourt nature', 'non_conforme', 'Emballage gonflé', 'Remboursé', 'ferme', 'normale', NULL, NULL, 'utilisateur_supprime', CURRENT_DATE - 400 + time '10:00', CURRENT_DATE - 390 + time '10:00', CURRENT_DATE - 395 + time '10:00', CURRENT_DATE - 390 + time '10:00'),
|
||||||
|
(302, $3, 4, 2, '3245678901324', 'SC-20', 'Sécateur', 'defectueux', 'Ressort cassé', NULL, 'attente_echange', 'faible', 'M. Simon', NULL, 'bench_directeur', CURRENT_DATE - 6 + time '14:00', CURRENT_DATE - 6 + time '14:00', NULL, NULL)`,
|
||||||
|
[`SAV-${year}-0004`, `SAV-${year - 1}-0042`, `SAV-${year}-0005`]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
// Lot 4, commandes et livraisons (cases/fournisseurs-commandes.ts) : cas que les
|
||||||
|
// données de base n'exercent pas.
|
||||||
|
type Query = (text: string, values?: unknown[]) => Promise<unknown>;
|
||||||
|
|
||||||
|
export async function seed(q: Query) {
|
||||||
|
// Commande de Metz dont le créateur n'existe plus (creator: null), en attente
|
||||||
|
// alors que sa livraison est livrée (synchronisation des statuts)
|
||||||
|
await q(`INSERT INTO orders (id, supplier_id, group_id, planned_date, quantity, unit, status, notes, created_by, created_at, updated_at) VALUES
|
||||||
|
(600, 2, 2, CURRENT_DATE + 5, 6, 'colis', 'pending', 'Créateur supprimé', 'utilisateur_supprime',
|
||||||
|
CURRENT_DATE - 2 + time '11:00', CURRENT_DATE - 2 + time '11:00')`);
|
||||||
|
|
||||||
|
await q(`INSERT INTO deliveries (id, order_id, supplier_id, group_id, scheduled_date, delivered_date, quantity, unit, status, notes, bl_number, bl_amount,
|
||||||
|
invoice_reference, invoice_amount, invoice_amount_ttc, due_date, reconciled, validated_at, control_validated, control_validated_by,
|
||||||
|
control_validated_at, created_by, created_at, updated_at) VALUES
|
||||||
|
-- Livraison de Nancy liée à la commande 600 de Metz : la commande n'est jamais jointe
|
||||||
|
(600, 600, 2, 1, CURRENT_DATE - 3, CURRENT_DATE - 3 + time '15:00', 6, 'colis', 'delivered', 'Commande d''un autre magasin', 'BL-6000', 120.00,
|
||||||
|
NULL, NULL, NULL, NULL, false, NULL, false, NULL, NULL, 'bench_manager', CURRENT_DATE - 4 + time '10:00', CURRENT_DATE - 3 + time '15:00'),
|
||||||
|
-- Bonduelle (rapprochement automatique), prévue, créateur supprimé ; même date
|
||||||
|
-- prévue que la livraison 7 (ordre à égalité de tri)
|
||||||
|
(601, NULL, 2, 1, CURRENT_DATE + 2, NULL, 3, 'colis', 'planned', NULL, NULL, NULL,
|
||||||
|
NULL, NULL, NULL, NULL, false, NULL, false, NULL, NULL, 'utilisateur_supprime', CURRENT_DATE - 1 + time '12:00', CURRENT_DATE - 1 + time '12:00'),
|
||||||
|
-- Bonduelle à Metz, rapprochée d'office, sans facture (API externe)
|
||||||
|
(602, NULL, 2, 2, CURRENT_DATE - 7, CURRENT_DATE - 7 + time '08:15', 4, 'colis', 'delivered', NULL, 'BL-6002', 88.40,
|
||||||
|
NULL, NULL, NULL, NULL, true, CURRENT_DATE - 7 + time '08:20', false, NULL, NULL, 'bench_directeur', CURRENT_DATE - 8 + time '10:00', CURRENT_DATE - 7 + time '08:20')`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// Lot 4, rapprochement (cases/fournisseurs-rapprochement.ts) : avoirs et échéancier
|
||||||
|
// (identifiants 700…).
|
||||||
|
// - avoirs hors de Nancy : un de Metz à vérifier dans NocoDB, un d'Épinal (aucun
|
||||||
|
// compte du banc n'y est affecté, sauf l'administrateur) créé par un compte
|
||||||
|
// supprimé depuis, et un avoir validé à la référence vide ;
|
||||||
|
// - livraisons que GET /api/payment-schedule complète avec NocoDB : échéance
|
||||||
|
// absente (trouvée, introuvable, ou sans échéance dans NocoDB), montant TTC nul
|
||||||
|
// (repli « TTC »), magasin d'Épinal sans configuration NocoDB ;
|
||||||
|
// - échéances aux bornes du mois courant (premier jour à minuit, dernière
|
||||||
|
// microseconde, premier jour du mois suivant) pour l'export CSV.
|
||||||
|
type Query = (text: string, values?: unknown[]) => Promise<unknown>;
|
||||||
|
|
||||||
|
export async function seed(q: Query) {
|
||||||
|
await q(`INSERT INTO avoirs (id, supplier_id, group_id, invoice_reference, amount, comment, commercial_processed, status, webhook_sent, nocodb_verified,
|
||||||
|
nocodb_verified_at, processed_at, created_by, created_at, updated_at) VALUES
|
||||||
|
(700, 2, 2, 'FAC-2006', 10.50, 'Remise oubliée', false, 'Demandé', false, false, NULL, NULL, 'bench_directeur',
|
||||||
|
CURRENT_DATE - 3 + time '11:00', CURRENT_DATE - 3 + time '11:00'),
|
||||||
|
(701, 1, 3, NULL, NULL, NULL, NULL, 'En attente de demande', NULL, NULL, NULL, NULL, 'compte_disparu',
|
||||||
|
CURRENT_DATE - 2 + time '11:00', CURRENT_DATE - 2 + time '11:00'),
|
||||||
|
(702, 4, 1, ' ', 0.00, '', true, 'Reçu', true, true, CURRENT_DATE - 1 + time '09:00', CURRENT_DATE - 1 + time '09:00', 'bench_employee',
|
||||||
|
CURRENT_DATE - 1 + time '08:00', CURRENT_DATE - 1 + time '09:00')`);
|
||||||
|
|
||||||
|
const month = `date_trunc('month', CURRENT_DATE::timestamp)`;
|
||||||
|
await q(`INSERT INTO deliveries (id, order_id, supplier_id, group_id, scheduled_date, delivered_date, quantity, unit, status, notes, bl_number, bl_amount,
|
||||||
|
invoice_reference, invoice_amount, invoice_amount_ttc, due_date, reconciled, validated_at, control_validated, created_by, created_at, updated_at) VALUES
|
||||||
|
-- Échéance et montant absents : retrouvés dans NocoDB (FAC-2002)
|
||||||
|
(700, NULL, 2, 1, CURRENT_DATE - 8, CURRENT_DATE - 8 + time '10:00', 3, 'colis', 'delivered', NULL, 'BL-7000', 300.00,
|
||||||
|
'FAC-2002', NULL, NULL, NULL, false, CURRENT_DATE - 8 + time '10:10', false, 'bench_manager', CURRENT_DATE - 9 + time '10:00', CURRENT_DATE - 8 + time '10:10'),
|
||||||
|
-- Référence introuvable dans NocoDB
|
||||||
|
(701, NULL, 1, 1, CURRENT_DATE - 7, CURRENT_DATE - 7 + time '10:00', 1, 'colis', 'delivered', NULL, 'BL-7001', 50.00,
|
||||||
|
'FAC-INCONNUE', NULL, NULL, NULL, false, CURRENT_DATE - 7 + time '10:10', false, 'bench_manager', CURRENT_DATE - 8 + time '09:00', CURRENT_DATE - 7 + time '10:10'),
|
||||||
|
-- Trouvée sans échéance (avoir AV-3001) : seul le montant est repris
|
||||||
|
(702, NULL, 3, 1, CURRENT_DATE - 6, CURRENT_DATE - 6 + time '10:00', 1, 'colis', 'delivered', NULL, NULL, NULL,
|
||||||
|
'AV-3001', NULL, NULL, NULL, false, CURRENT_DATE - 6 + time '10:10', false, 'bench_manager', CURRENT_DATE - 7 + time '09:00', CURRENT_DATE - 6 + time '10:10'),
|
||||||
|
-- Montant TTC nul : repli « TTC », qui réécrit le HT et l'échéance de NocoDB (FAC-2006)
|
||||||
|
(703, NULL, 4, 1, CURRENT_DATE - 5, CURRENT_DATE - 5 + time '10:00', 2, 'palettes', 'delivered', NULL, 'BL-7003', 450.25,
|
||||||
|
'FAC-2006', 450.25, 0.00, CURRENT_DATE + 3 + time '00:00', false, CURRENT_DATE - 5 + time '10:10', false, 'bench_manager', CURRENT_DATE - 6 + time '09:00', CURRENT_DATE - 5 + time '10:10'),
|
||||||
|
-- Référence vide : hors échéancier
|
||||||
|
(704, NULL, 2, 1, CURRENT_DATE - 4, CURRENT_DATE - 4 + time '10:00', 1, 'colis', 'delivered', NULL, NULL, NULL,
|
||||||
|
'', 12.00, 14.40, CURRENT_DATE + 4, false, CURRENT_DATE - 4 + time '10:10', false, 'bench_manager', CURRENT_DATE - 5 + time '09:00', CURRENT_DATE - 4 + time '10:10'),
|
||||||
|
-- Metz : échéance absente, retrouvée dans NocoDB (FAC-2001)
|
||||||
|
(705, NULL, 1, 2, CURRENT_DATE - 4, CURRENT_DATE - 4 + time '11:00', 4, 'colis', 'delivered', NULL, 'BL-7005', 1200.00,
|
||||||
|
'FAC-2001', NULL, NULL, NULL, false, CURRENT_DATE - 4 + time '11:10', false, 'bench_directeur', CURRENT_DATE - 5 + time '11:00', CURRENT_DATE - 4 + time '11:10'),
|
||||||
|
-- Épinal : magasin sans configuration NocoDB
|
||||||
|
(706, NULL, 3, 3, CURRENT_DATE - 3, CURRENT_DATE - 3 + time '11:00', 1, 'palettes', 'delivered', NULL, 'BL-7006', 99.00,
|
||||||
|
'FAC-2004', NULL, NULL, NULL, false, CURRENT_DATE - 3 + time '11:10', false, 'admin_local', CURRENT_DATE - 4 + time '11:00', CURRENT_DATE - 3 + time '11:10'),
|
||||||
|
(707, NULL, 4, 3, CURRENT_DATE - 3, CURRENT_DATE - 3 + time '12:00', 1, 'palettes', 'delivered', NULL, 'BL-7007', 80.00,
|
||||||
|
'FAC-7007', 80.00, 96.00, ${month} + interval '12 days', false, CURRENT_DATE - 3 + time '12:10', false, 'admin_local', CURRENT_DATE - 4 + time '12:00', CURRENT_DATE - 3 + time '12:10'),
|
||||||
|
-- Bornes du mois courant (export CSV)
|
||||||
|
(708, NULL, 1, 1, CURRENT_DATE - 2, CURRENT_DATE - 2 + time '09:00', 1, 'colis', 'delivered', NULL, 'BL-7008', 100.00,
|
||||||
|
'FAC-7008', 100.00, 120.00, ${month}, false, CURRENT_DATE - 2 + time '09:10', false, 'bench_manager', CURRENT_DATE - 3 + time '08:00', CURRENT_DATE - 2 + time '09:10'),
|
||||||
|
(709, NULL, 2, 1, CURRENT_DATE - 2, CURRENT_DATE - 2 + time '10:00', 1, 'colis', 'delivered', NULL, 'BL-7009', 20.10,
|
||||||
|
'FAC-7009', 20.10, 24.12, ${month} + interval '1 month' - interval '1 microsecond', false, CURRENT_DATE - 2 + time '10:10', false, 'bench_manager', CURRENT_DATE - 3 + time '08:30', CURRENT_DATE - 2 + time '10:10'),
|
||||||
|
(710, NULL, 4, 1, CURRENT_DATE - 1, CURRENT_DATE - 1 + time '09:00', 1, 'colis', 'delivered', NULL, 'BL-7010', 55.55,
|
||||||
|
'FAC-7010', 55.55, 66.66, ${month} + interval '1 month', false, CURRENT_DATE - 1 + time '09:10', false, 'bench_manager', CURRENT_DATE - 2 + time '08:00', CURRENT_DATE - 1 + time '09:10'),
|
||||||
|
(711, NULL, 3, 1, CURRENT_DATE - 1, CURRENT_DATE - 1 + time '10:00', 1, 'colis', 'delivered', NULL, 'BL-7011', 0.10,
|
||||||
|
'FAC-7011', 0.10, 0.20, ${month} + interval '14 days 10:30', false, CURRENT_DATE - 1 + time '10:10', false, 'bench_manager', CURRENT_DATE - 2 + time '08:30', CURRENT_DATE - 1 + time '10:10'),
|
||||||
|
-- Échéances de NocoDB au format JJ/MM/AAAA (illisible par new Date) et MM/JJ/AAAA
|
||||||
|
(712, NULL, 2, 1, CURRENT_DATE - 1, CURRENT_DATE - 1 + time '11:00', 1, 'colis', 'delivered', NULL, 'BL-1007', 99.90,
|
||||||
|
'FAC-2007', NULL, NULL, NULL, false, CURRENT_DATE - 1 + time '11:10', false, 'bench_manager', CURRENT_DATE - 2 + time '09:00', CURRENT_DATE - 1 + time '11:10'),
|
||||||
|
(713, NULL, 3, 1, CURRENT_DATE - 1, CURRENT_DATE - 1 + time '12:00', 1, 'colis', 'delivered', NULL, 'BL-1008', 12.34,
|
||||||
|
'FAC-2008', NULL, NULL, NULL, true, CURRENT_DATE - 1 + time '12:10', false, 'bench_manager', CURRENT_DATE - 2 + time '09:30', CURRENT_DATE - 1 + time '12:10')`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
// Lot 4, services partagés (cases/fournisseurs-services.ts) : entrées du cache des
|
||||||
|
// factures pour exercer le marquage « rapproché » et le diagnostic du cache.
|
||||||
|
type Query = (text: string, values?: unknown[]) => Promise<unknown>;
|
||||||
|
|
||||||
|
export async function seed(q: Query) {
|
||||||
|
await q(`INSERT INTO invoice_verification_cache (id, cache_key, group_id, invoice_reference, supplier_name, invoice_amount, invoice_amount_ttc,
|
||||||
|
due_date, exists, match_type, error_message, cache_hit, api_call_time, is_reconciled, expires_at, created_at, updated_at) VALUES
|
||||||
|
-- Livraison 6 validée (Metz), entrée encore valable : marquée par la mise à jour en lot
|
||||||
|
(400, '2_fac-2006', 2, 'FAC-2006', 'Jardin & Co', 450.25, 540.30, CURRENT_DATE - 5, true, 'invoice_reference', NULL, false, 80, false,
|
||||||
|
CURRENT_DATE + 2, CURRENT_DATE - 3 + time '10:00', CURRENT_DATE - 3 + time '10:00'),
|
||||||
|
-- Numéro de BL de la livraison 8, validée : recherche infructueuse en cache
|
||||||
|
(401, '1_bl-0900', 1, 'BL-0900', NULL, NULL, NULL, NULL, false, 'none', 'Aucune correspondance trouvée dans NocoDB', false, NULL, false,
|
||||||
|
CURRENT_DATE + 1, CURRENT_DATE - 1 + time '09:00', CURRENT_DATE - 1 + time '09:00'),
|
||||||
|
-- Facture de la livraison 8, expirée : ni marquée, et supprimée à sa lecture
|
||||||
|
(402, '1_fac-1900', 1, 'FAC-1900', 'Lactalis', 800.00, 960.00, CURRENT_DATE - 350, true, 'invoice_reference', NULL, false, 70, false,
|
||||||
|
CURRENT_DATE - 1, CURRENT_DATE - 2 + time '09:00', CURRENT_DATE - 2 + time '09:00'),
|
||||||
|
-- Clé au format du diagnostic (référence_magasin), expirée : supprimée par GET /api/cache/diagnosis
|
||||||
|
(403, 'fac-2001_1', 1, 'FAC-2001', 'Lactalis', 1250.50, 1500.60, NULL, true, 'invoice_reference', NULL, false, NULL, false,
|
||||||
|
CURRENT_DATE - 2, CURRENT_DATE - 30 + time '09:00', CURRENT_DATE - 30 + time '09:00'),
|
||||||
|
-- Même format, valable : trouvée par le diagnostic
|
||||||
|
(404, 'fac-2006_2', 2, 'FAC-2006', 'Jardin & Co', 450.25, 540.30, NULL, true, 'invoice_reference', NULL, false, NULL, true,
|
||||||
|
CURRENT_DATE + 3, CURRENT_DATE - 30 + time '09:00', CURRENT_DATE - 30 + time '09:00')`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// Données du lot 5 (administration) : des sauvegardes en plus de celle de
|
||||||
|
// seed.ts, pour que la liste soit tronquée à dix et que la création d'une
|
||||||
|
// sauvegarde fasse le ménage des plus anciennes (backupService.cleanOldBackups).
|
||||||
|
// banc-sauvegarde-500 a un fichier (sauvegardes/backup_banc_500.sql), copié
|
||||||
|
// par compare.ts dans le dossier de sauvegardes de chaque serveur, comme celui
|
||||||
|
// de banc-sauvegarde-1.
|
||||||
|
type Query = (text: string, values?: unknown[]) => Promise<unknown>;
|
||||||
|
|
||||||
|
export async function seed(q: Query) {
|
||||||
|
await q(`INSERT INTO database_backups (id, filename, description, size, created_at, created_by, tables_count, status, backup_type) VALUES
|
||||||
|
('banc-sauvegarde-500', 'backup_banc_500.sql', 'Manuel backup du banc', 64, CURRENT_DATE - 2 + time '14:00', 'admin_local', 26, 'completed', 'manual')`);
|
||||||
|
// Neuf anciennes sauvegardes automatiques, sans fichier, un jour d'écart chacune
|
||||||
|
for (let n = 1; n <= 9; n++) {
|
||||||
|
await q(`INSERT INTO database_backups (id, filename, description, size, created_at, created_by, tables_count, status, backup_type)
|
||||||
|
VALUES ($1, $2, 'Automatique backup du banc', $3, CURRENT_DATE - $4::int + time '02:00', 'system', 26, 'completed', 'automatic')`,
|
||||||
|
[`banc-sauvegarde-${500 + n}`, `backup_banc_${500 + n}.sql`, 1024 * n, 2 + n]);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loaded 100 of 204 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user