Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97d63b2097 | ||
|
|
af6a854400 | ||
|
|
bffdca1297 | ||
|
|
600627c632 | ||
|
|
6760f43e87 | ||
|
|
b964e137bf | ||
|
|
bd3bb5e64a | ||
|
|
6f1a39e4b2 | ||
|
|
2274c9bb4a | ||
|
|
944d99733c | ||
|
|
8a27b310d2 | ||
|
|
6bb61cdbcc | ||
|
|
d384271a30 | ||
|
|
f4c582b217 | ||
|
|
5d3e5357a0 | ||
|
|
a5785f0244 | ||
|
|
3f2660c506 | ||
|
|
3eea2eb47b | ||
|
|
c375229c05 | ||
|
|
bc401963c1 | ||
|
|
6cf779092e | ||
|
|
d09821ddc0 | ||
|
|
a7e44f0601 | ||
|
|
1b9e3ac493 | ||
|
|
4220131432 | ||
|
|
0a816fa881 | ||
|
|
37a8d2b40c | ||
|
|
9dc6d9567c | ||
|
|
f5bc114a82 | ||
|
|
af3800aa09 | ||
|
|
9de717387f | ||
|
|
012024a293 | ||
|
|
baaa27af50 | ||
|
|
02645c34a5 | ||
|
|
02b70d6b59 | ||
|
|
ee4fca64d0 | ||
|
|
753b301066 | ||
|
|
693d9460cb | ||
|
|
a430aa1910 |
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/
|
||||||
@@ -22,3 +22,17 @@ POSTGRES_PORT=5434
|
|||||||
# Note de sécurité:
|
# Note de sécurité:
|
||||||
# Les identifiants sont préconfigurés pour simplifier le déploiement.
|
# Les identifiants sont préconfigurés pour simplifier le déploiement.
|
||||||
# En production, vous pouvez les modifier si nécessaire.
|
# En production, vous pouvez les modifier si nécessaire.
|
||||||
|
# Chiffrement des secrets en base (mots de passe SMTP, jetons NocoDB).
|
||||||
|
# Repli sur SESSION_SECRET si absent. ATTENTION : changer cette clé rend
|
||||||
|
# les secrets déjà chiffrés illisibles.
|
||||||
|
ENCRYPTION_KEY=
|
||||||
|
|
||||||
|
# Route de secours POST /api/emergency-admin-reset : désactivée si vide.
|
||||||
|
# Ne définir qu'en cas de besoin, puis retirer.
|
||||||
|
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
|
||||||
@@ -1,6 +1,17 @@
|
|||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
.DS_Store
|
.DS_Store
|
||||||
server/public
|
|
||||||
vite.config.ts.*
|
vite.config.ts.*
|
||||||
*.tar.gz
|
*.tar.gz
|
||||||
|
|
||||||
|
# Secrets et fichiers de session : ne jamais committer
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
cookie*.txt
|
||||||
|
|
||||||
|
# Artefacts de debug locaux
|
||||||
|
*.log
|
||||||
|
scratch/
|
||||||
|
|
||||||
|
# Serveur Node de référence du banc (scripts/bench/reference-node.sh)
|
||||||
|
.bench-reference/
|
||||||
@@ -1,118 +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 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
|
|
||||||
|
|
||||||
# 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"]
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
-- MIGRATION URGENTE PRODUCTION DLC
|
|
||||||
-- Exécutez cette commande directement sur votre serveur
|
|
||||||
|
|
||||||
-- Ajout des colonnes DLC stock épuisé
|
|
||||||
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;
|
|
||||||
|
|
||||||
-- Index pour performance
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_dlc_products_stock_epuise ON dlc_products(stock_epuise);
|
|
||||||
|
|
||||||
-- Initialisation des valeurs
|
|
||||||
UPDATE dlc_products
|
|
||||||
SET stock_epuise = false
|
|
||||||
WHERE stock_epuise IS NULL;
|
|
||||||
|
|
||||||
-- Vérification
|
|
||||||
SELECT
|
|
||||||
'Migration DLC terminée' as status,
|
|
||||||
COUNT(*) as colonnes_ajoutees
|
|
||||||
FROM information_schema.columns
|
|
||||||
WHERE table_name = 'dlc_products'
|
|
||||||
AND column_name IN ('stock_epuise', 'stock_epuise_by', 'stock_epuise_at');
|
|
||||||
@@ -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
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 274 KiB |
@@ -1,245 +0,0 @@
|
|||||||
|
|
||||||
Images
|
|
||||||
Networks
|
|
||||||
Volumes
|
|
||||||
Events
|
|
||||||
|
|
||||||
Host
|
|
||||||
|
|
||||||
Administration
|
|
||||||
|
|
||||||
User-related
|
|
||||||
|
|
||||||
|
|
||||||
Environment-related
|
|
||||||
|
|
||||||
Registries
|
|
||||||
|
|
||||||
Logs
|
|
||||||
|
|
||||||
Notifications
|
|
||||||
|
|
||||||
Settings
|
|
||||||
|
|
||||||
New version available 2.33.3
|
|
||||||
DismissSee what's new
|
|
||||||
Community Edition
|
|
||||||
2.27.6 LTS
|
|
||||||
Containers>logiflow-logiflow-1>Logs
|
|
||||||
Container logs
|
|
||||||
|
|
||||||
|
|
||||||
admin
|
|
||||||
Log viewer settings
|
|
||||||
Auto-refresh logs
|
|
||||||
Wrap lines
|
|
||||||
Display timestamps
|
|
||||||
Fetch
|
|
||||||
All logs
|
|
||||||
Search
|
|
||||||
Filter...
|
|
||||||
Lines
|
|
||||||
100
|
|
||||||
Actions
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
storeId: 2,
|
|
||||||
|
|
||||||
createdBy: 'admin',
|
|
||||||
|
|
||||||
createdAt: 2025-09-24T11:26:13.521Z
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
|
|
||||||
id: 17,
|
|
||||||
|
|
||||||
title: 'Module Avoir',
|
|
||||||
|
|
||||||
content: "Le module d'avoir est en place",
|
|
||||||
|
|
||||||
type: 'error',
|
|
||||||
|
|
||||||
storeId: 1,
|
|
||||||
|
|
||||||
createdBy: 'admin',
|
|
||||||
|
|
||||||
createdAt: 2025-09-09T07:47:47.650Z
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
|
|
||||||
id: 16,
|
|
||||||
|
|
||||||
title: 'Mise à jour Prix',
|
|
||||||
|
|
||||||
content: 'Merci de mettre vos prix a jour tous les matins, certain magasin ne le font pas.',
|
|
||||||
|
|
||||||
type: 'success',
|
|
||||||
|
|
||||||
storeId: null,
|
|
||||||
|
|
||||||
createdBy: 'admin',
|
|
||||||
|
|
||||||
createdAt: 2025-08-18T10:45:08.585Z
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
|
|
||||||
id: 8,
|
|
||||||
|
|
||||||
title: 'Bon de Livraison',
|
|
||||||
|
|
||||||
content: 'Lorsque vous rentrez un numéro de bon de livraison, faite le a l identique, même les majuscules. Pour My candy Shop, prendre le numéro de commande.',
|
|
||||||
|
|
||||||
type: 'info',
|
|
||||||
|
|
||||||
storeId: null,
|
|
||||||
|
|
||||||
createdBy: 'admin_local',
|
|
||||||
|
|
||||||
createdAt: 2025-08-07T11:04:38.123Z
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
]
|
|
||||||
|
|
||||||
🔍 [PRODUCTION] Looking for user with username: admin
|
|
||||||
|
|
||||||
🔍 [PRODUCTION] Looking for user with username: admin
|
|
||||||
|
|
||||||
🔍 [PRODUCTION] Looking for user with username: admin
|
|
||||||
|
|
||||||
🔍 [PRODUCTION] Looking for user with username: admin_local
|
|
||||||
|
|
||||||
🔍 [PRODUCTION] User search result: Found
|
|
||||||
|
|
||||||
🔍 [PRODUCTION] User search result: Found
|
|
||||||
|
|
||||||
🔍 [PRODUCTION] User search result: Found
|
|
||||||
|
|
||||||
❌ [PRODUCTION] User not found with username: admin_local
|
|
||||||
|
|
||||||
🔍 [PRODUCTION] User search result: Not found
|
|
||||||
|
|
||||||
🎯 [PRODUCTION] Found announcements: 4
|
|
||||||
|
|
||||||
GET /api/announcements 304 in 18ms
|
|
||||||
|
|
||||||
📢 [SERVER] Fetching announcements for user: _1754936203292 environment: production
|
|
||||||
|
|
||||||
📢 [SERVER] User found: { username: 'ff292', role: 'employee' }
|
|
||||||
|
|
||||||
🎯 [PRODUCTION] Using PostgreSQL DASHBOARD_MESSAGES table
|
|
||||||
|
|
||||||
GET /api/groups 304 in 4ms
|
|
||||||
|
|
||||||
🔍 [PRODUCTION] Raw messages from DB: 4 items: [
|
|
||||||
|
|
||||||
{
|
|
||||||
|
|
||||||
id: 20,
|
|
||||||
|
|
||||||
title: 'DLC',
|
|
||||||
|
|
||||||
content: 'Attention des DLC sont en expiré bientôt ou expiré, il faut s en occuper',
|
|
||||||
|
|
||||||
type: 'success',
|
|
||||||
|
|
||||||
storeId: 2,
|
|
||||||
|
|
||||||
createdBy: 'admin',
|
|
||||||
|
|
||||||
createdAt: 2025-09-24T11:26:13.521Z
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
|
|
||||||
id: 17,
|
|
||||||
|
|
||||||
title: 'Module Avoir',
|
|
||||||
|
|
||||||
content: "Le module d'avoir est en place",
|
|
||||||
|
|
||||||
type: 'error',
|
|
||||||
|
|
||||||
storeId: 1,
|
|
||||||
|
|
||||||
createdBy: 'admin',
|
|
||||||
|
|
||||||
createdAt: 2025-09-09T07:47:47.650Z
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
|
|
||||||
id: 16,
|
|
||||||
|
|
||||||
title: 'Mise à jour Prix',
|
|
||||||
|
|
||||||
content: 'Merci de mettre vos prix a jour tous les matins, certain magasin ne le font pas.',
|
|
||||||
|
|
||||||
type: 'success',
|
|
||||||
|
|
||||||
storeId: null,
|
|
||||||
|
|
||||||
createdBy: 'admin',
|
|
||||||
|
|
||||||
createdAt: 2025-08-18T10:45:08.585Z
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
|
|
||||||
id: 8,
|
|
||||||
|
|
||||||
title: 'Bon de Livraison',
|
|
||||||
|
|
||||||
content: 'Lorsque vous rentrez un numéro de bon de livraison, faite le a l identique, même les majuscules. Pour My candy Shop, prendre le numéro de commande.',
|
|
||||||
|
|
||||||
type: 'info',
|
|
||||||
|
|
||||||
storeId: null,
|
|
||||||
|
|
||||||
createdBy: 'admin_local',
|
|
||||||
|
|
||||||
createdAt: 2025-08-07T11:04:38.123Z
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
]
|
|
||||||
|
|
||||||
🔍 [PRODUCTION] Looking for user with username: admin
|
|
||||||
|
|
||||||
🔍 [PRODUCTION] Looking for user with username: admin
|
|
||||||
|
|
||||||
🔍 [PRODUCTION] Looking for user with username: admin
|
|
||||||
|
|
||||||
🔍 [PRODUCTION] Looking for user with username: admin_local
|
|
||||||
|
|
||||||
🔍 [PRODUCTION] User search result: Found
|
|
||||||
|
|
||||||
🔍 [PRODUCTION] User search result: Found
|
|
||||||
|
|
||||||
🔍 [PRODUCTION] User search result: Found
|
|
||||||
|
|
||||||
🔍 [PRODUCTION] User search result: Not found
|
|
||||||
|
|
||||||
❌ [PRODUCTION] User not found with username: admin_local
|
|
||||||
|
|
||||||
🎯 [PRODUCTION] Found announcements: 4
|
|
||||||
|
|
||||||
GET /api/announcements 304 in 8ms
|
|
||||||
|
|
||||||
GET /api/user 304 in 13ms
|
|
||||||
|
|
||||||
GET /api/suppliers 304 in 4ms
|
|
||||||
|
|
||||||
GET /api/dlc-products 304 in 58ms
|
|
||||||
|
|
||||||
@@ -1,245 +0,0 @@
|
|||||||
|
|
||||||
Images
|
|
||||||
Networks
|
|
||||||
Volumes
|
|
||||||
Events
|
|
||||||
|
|
||||||
Host
|
|
||||||
|
|
||||||
Administration
|
|
||||||
|
|
||||||
User-related
|
|
||||||
|
|
||||||
|
|
||||||
Environment-related
|
|
||||||
|
|
||||||
Registries
|
|
||||||
|
|
||||||
Logs
|
|
||||||
|
|
||||||
Notifications
|
|
||||||
|
|
||||||
Settings
|
|
||||||
|
|
||||||
New version available 2.33.3
|
|
||||||
DismissSee what's new
|
|
||||||
Community Edition
|
|
||||||
2.27.6 LTS
|
|
||||||
Containers>logiflow-logiflow-1>Logs
|
|
||||||
Container logs
|
|
||||||
|
|
||||||
|
|
||||||
admin
|
|
||||||
Log viewer settings
|
|
||||||
Auto-refresh logs
|
|
||||||
Wrap lines
|
|
||||||
Display timestamps
|
|
||||||
Fetch
|
|
||||||
All logs
|
|
||||||
Search
|
|
||||||
Filter...
|
|
||||||
Lines
|
|
||||||
100
|
|
||||||
Actions
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
storeId: 2,
|
|
||||||
|
|
||||||
createdBy: 'admin',
|
|
||||||
|
|
||||||
createdAt: 2025-09-24T11:26:13.521Z
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
|
|
||||||
id: 17,
|
|
||||||
|
|
||||||
title: 'Module Avoir',
|
|
||||||
|
|
||||||
content: "Le module d'avoir est en place",
|
|
||||||
|
|
||||||
type: 'error',
|
|
||||||
|
|
||||||
storeId: 1,
|
|
||||||
|
|
||||||
createdBy: 'admin',
|
|
||||||
|
|
||||||
createdAt: 2025-09-09T07:47:47.650Z
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
|
|
||||||
id: 16,
|
|
||||||
|
|
||||||
title: 'Mise à jour Prix',
|
|
||||||
|
|
||||||
content: 'Merci de mettre vos prix a jour tous les matins, certain magasin ne le font pas.',
|
|
||||||
|
|
||||||
type: 'success',
|
|
||||||
|
|
||||||
storeId: null,
|
|
||||||
|
|
||||||
createdBy: 'admin',
|
|
||||||
|
|
||||||
createdAt: 2025-08-18T10:45:08.585Z
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
|
|
||||||
id: 8,
|
|
||||||
|
|
||||||
title: 'Bon de Livraison',
|
|
||||||
|
|
||||||
content: 'Lorsque vous rentrez un numéro de bon de livraison, faite le a l identique, même les majuscules. Pour My candy Shop, prendre le numéro de commande.',
|
|
||||||
|
|
||||||
type: 'info',
|
|
||||||
|
|
||||||
storeId: null,
|
|
||||||
|
|
||||||
createdBy: 'admin_local',
|
|
||||||
|
|
||||||
createdAt: 2025-08-07T11:04:38.123Z
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
]
|
|
||||||
|
|
||||||
🔍 [PRODUCTION] Looking for user with username: admin
|
|
||||||
|
|
||||||
🔍 [PRODUCTION] Looking for user with username: admin
|
|
||||||
|
|
||||||
🔍 [PRODUCTION] Looking for user with username: admin
|
|
||||||
|
|
||||||
🔍 [PRODUCTION] Looking for user with username: admin_local
|
|
||||||
|
|
||||||
🔍 [PRODUCTION] User search result: Found
|
|
||||||
|
|
||||||
🔍 [PRODUCTION] User search result: Found
|
|
||||||
|
|
||||||
🔍 [PRODUCTION] User search result: Found
|
|
||||||
|
|
||||||
❌ [PRODUCTION] User not found with username: admin_local
|
|
||||||
|
|
||||||
🔍 [PRODUCTION] User search result: Not found
|
|
||||||
|
|
||||||
🎯 [PRODUCTION] Found announcements: 4
|
|
||||||
|
|
||||||
GET /api/announcements 304 in 18ms
|
|
||||||
|
|
||||||
📢 [SERVER] Fetching announcements for user: _1754936203292 environment: production
|
|
||||||
|
|
||||||
📢 [SERVER] User found: { username: 'ff292', role: 'employee' }
|
|
||||||
|
|
||||||
🎯 [PRODUCTION] Using PostgreSQL DASHBOARD_MESSAGES table
|
|
||||||
|
|
||||||
GET /api/groups 304 in 4ms
|
|
||||||
|
|
||||||
🔍 [PRODUCTION] Raw messages from DB: 4 items: [
|
|
||||||
|
|
||||||
{
|
|
||||||
|
|
||||||
id: 20,
|
|
||||||
|
|
||||||
title: 'DLC',
|
|
||||||
|
|
||||||
content: 'Attention des DLC sont en expiré bientôt ou expiré, il faut s en occuper',
|
|
||||||
|
|
||||||
type: 'success',
|
|
||||||
|
|
||||||
storeId: 2,
|
|
||||||
|
|
||||||
createdBy: 'admin',
|
|
||||||
|
|
||||||
createdAt: 2025-09-24T11:26:13.521Z
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
|
|
||||||
id: 17,
|
|
||||||
|
|
||||||
title: 'Module Avoir',
|
|
||||||
|
|
||||||
content: "Le module d'avoir est en place",
|
|
||||||
|
|
||||||
type: 'error',
|
|
||||||
|
|
||||||
storeId: 1,
|
|
||||||
|
|
||||||
createdBy: 'admin',
|
|
||||||
|
|
||||||
createdAt: 2025-09-09T07:47:47.650Z
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
|
|
||||||
id: 16,
|
|
||||||
|
|
||||||
title: 'Mise à jour Prix',
|
|
||||||
|
|
||||||
content: 'Merci de mettre vos prix a jour tous les matins, certain magasin ne le font pas.',
|
|
||||||
|
|
||||||
type: 'success',
|
|
||||||
|
|
||||||
storeId: null,
|
|
||||||
|
|
||||||
createdBy: 'admin',
|
|
||||||
|
|
||||||
createdAt: 2025-08-18T10:45:08.585Z
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
|
|
||||||
id: 8,
|
|
||||||
|
|
||||||
title: 'Bon de Livraison',
|
|
||||||
|
|
||||||
content: 'Lorsque vous rentrez un numéro de bon de livraison, faite le a l identique, même les majuscules. Pour My candy Shop, prendre le numéro de commande.',
|
|
||||||
|
|
||||||
type: 'info',
|
|
||||||
|
|
||||||
storeId: null,
|
|
||||||
|
|
||||||
createdBy: 'admin_local',
|
|
||||||
|
|
||||||
createdAt: 2025-08-07T11:04:38.123Z
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
]
|
|
||||||
|
|
||||||
🔍 [PRODUCTION] Looking for user with username: admin
|
|
||||||
|
|
||||||
🔍 [PRODUCTION] Looking for user with username: admin
|
|
||||||
|
|
||||||
🔍 [PRODUCTION] Looking for user with username: admin
|
|
||||||
|
|
||||||
🔍 [PRODUCTION] Looking for user with username: admin_local
|
|
||||||
|
|
||||||
🔍 [PRODUCTION] User search result: Found
|
|
||||||
|
|
||||||
🔍 [PRODUCTION] User search result: Found
|
|
||||||
|
|
||||||
🔍 [PRODUCTION] User search result: Found
|
|
||||||
|
|
||||||
🔍 [PRODUCTION] User search result: Not found
|
|
||||||
|
|
||||||
❌ [PRODUCTION] User not found with username: admin_local
|
|
||||||
|
|
||||||
🎯 [PRODUCTION] Found announcements: 4
|
|
||||||
|
|
||||||
GET /api/announcements 304 in 8ms
|
|
||||||
|
|
||||||
GET /api/user 304 in 13ms
|
|
||||||
|
|
||||||
GET /api/suppliers 304 in 4ms
|
|
||||||
|
|
||||||
GET /api/dlc-products 304 in 58ms
|
|
||||||
|
|
||||||
@@ -1,202 +0,0 @@
|
|||||||
|
|
||||||
|
|
||||||
storeId: 2,
|
|
||||||
|
|
||||||
createdBy: 'admin',
|
|
||||||
|
|
||||||
createdAt: 2025-09-24T11:26:13.521Z
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
|
|
||||||
id: 17,
|
|
||||||
|
|
||||||
title: 'Module Avoir',
|
|
||||||
|
|
||||||
content: "Le module d'avoir est en place",
|
|
||||||
|
|
||||||
type: 'error',
|
|
||||||
|
|
||||||
storeId: 1,
|
|
||||||
|
|
||||||
createdBy: 'admin',
|
|
||||||
|
|
||||||
createdAt: 2025-09-09T07:47:47.650Z
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
|
|
||||||
id: 16,
|
|
||||||
|
|
||||||
title: 'Mise à jour Prix',
|
|
||||||
|
|
||||||
content: 'Merci de mettre vos prix a jour tous les matins, certain magasin ne le font pas.',
|
|
||||||
|
|
||||||
type: 'success',
|
|
||||||
|
|
||||||
storeId: null,
|
|
||||||
|
|
||||||
createdBy: 'admin',
|
|
||||||
|
|
||||||
createdAt: 2025-08-18T10:45:08.585Z
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
|
|
||||||
id: 8,
|
|
||||||
|
|
||||||
title: 'Bon de Livraison',
|
|
||||||
|
|
||||||
content: 'Lorsque vous rentrez un numéro de bon de livraison, faite le a l identique, même les majuscules. Pour My candy Shop, prendre le numéro de commande.',
|
|
||||||
|
|
||||||
type: 'info',
|
|
||||||
|
|
||||||
storeId: null,
|
|
||||||
|
|
||||||
createdBy: 'admin_local',
|
|
||||||
|
|
||||||
createdAt: 2025-08-07T11:04:38.123Z
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
]
|
|
||||||
|
|
||||||
🔍 [PRODUCTION] Looking for user with username: admin
|
|
||||||
|
|
||||||
🔍 [PRODUCTION] Looking for user with username: admin
|
|
||||||
|
|
||||||
🔍 [PRODUCTION] Looking for user with username: admin
|
|
||||||
|
|
||||||
🔍 [PRODUCTION] Looking for user with username: admin_local
|
|
||||||
|
|
||||||
❌ [PRODUCTION] User not found with username: admin_local
|
|
||||||
|
|
||||||
🔍 [PRODUCTION] User search result: Found
|
|
||||||
|
|
||||||
🔍 [PRODUCTION] User search result: Not found
|
|
||||||
|
|
||||||
🔍 [PRODUCTION] User search result: Found
|
|
||||||
|
|
||||||
🔍 [PRODUCTION] User search result: Found
|
|
||||||
|
|
||||||
🎯 [PRODUCTION] Found announcements: 4
|
|
||||||
|
|
||||||
GET /api/announcements 304 in 8ms
|
|
||||||
|
|
||||||
📢 [SERVER] Fetching announcements for user: _1754936203292 environment: production
|
|
||||||
|
|
||||||
📢 [SERVER] User found: { username: 'ff292', role: 'employee' }
|
|
||||||
|
|
||||||
🎯 [PRODUCTION] Using PostgreSQL DASHBOARD_MESSAGES table
|
|
||||||
|
|
||||||
🔍 [PRODUCTION] Raw messages from DB: 4 items: [
|
|
||||||
|
|
||||||
{
|
|
||||||
|
|
||||||
id: 20,
|
|
||||||
|
|
||||||
title: 'DLC',
|
|
||||||
|
|
||||||
content: 'Attention des DLC sont en expiré bientôt ou expiré, il faut s en occuper',
|
|
||||||
|
|
||||||
type: 'success',
|
|
||||||
|
|
||||||
storeId: 2,
|
|
||||||
|
|
||||||
createdBy: 'admin',
|
|
||||||
|
|
||||||
createdAt: 2025-09-24T11:26:13.521Z
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
|
|
||||||
id: 17,
|
|
||||||
|
|
||||||
title: 'Module Avoir',
|
|
||||||
|
|
||||||
content: "Le module d'avoir est en place",
|
|
||||||
|
|
||||||
type: 'error',
|
|
||||||
|
|
||||||
storeId: 1,
|
|
||||||
|
|
||||||
createdBy: 'admin',
|
|
||||||
|
|
||||||
createdAt: 2025-09-09T07:47:47.650Z
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
|
|
||||||
id: 16,
|
|
||||||
|
|
||||||
title: 'Mise à jour Prix',
|
|
||||||
|
|
||||||
content: 'Merci de mettre vos prix a jour tous les matins, certain magasin ne le font pas.',
|
|
||||||
|
|
||||||
type: 'success',
|
|
||||||
|
|
||||||
storeId: null,
|
|
||||||
|
|
||||||
createdBy: 'admin',
|
|
||||||
|
|
||||||
createdAt: 2025-08-18T10:45:08.585Z
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
|
|
||||||
id: 8,
|
|
||||||
|
|
||||||
title: 'Bon de Livraison',
|
|
||||||
|
|
||||||
content: 'Lorsque vous rentrez un numéro de bon de livraison, faite le a l identique, même les majuscules. Pour My candy Shop, prendre le numéro de commande.',
|
|
||||||
|
|
||||||
type: 'info',
|
|
||||||
|
|
||||||
storeId: null,
|
|
||||||
|
|
||||||
createdBy: 'admin_local',
|
|
||||||
|
|
||||||
createdAt: 2025-08-07T11:04:38.123Z
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
]
|
|
||||||
|
|
||||||
🔍 [PRODUCTION] Looking for user with username: admin
|
|
||||||
|
|
||||||
🔍 [PRODUCTION] Looking for user with username: admin
|
|
||||||
|
|
||||||
🔍 [PRODUCTION] Looking for user with username: admin
|
|
||||||
|
|
||||||
🔍 [PRODUCTION] Looking for user with username: admin_local
|
|
||||||
|
|
||||||
🔍 [PRODUCTION] User search result: Found
|
|
||||||
|
|
||||||
❌ [PRODUCTION] User not found with username: admin_local
|
|
||||||
|
|
||||||
🔍 [PRODUCTION] User search result: Found
|
|
||||||
|
|
||||||
🔍 [PRODUCTION] User search result: Found
|
|
||||||
|
|
||||||
🔍 [PRODUCTION] User search result: Not found
|
|
||||||
|
|
||||||
🎯 [PRODUCTION] Found announcements: 4
|
|
||||||
|
|
||||||
GET /api/announcements 304 in 9ms
|
|
||||||
|
|
||||||
GET /api/groups 304 in 3ms
|
|
||||||
|
|
||||||
GET /api/user 304 in 5ms
|
|
||||||
|
|
||||||
GET /api/suppliers 304 in 4ms
|
|
||||||
|
|
||||||
GET /api/dlc-products 304 in 45ms
|
|
||||||
|
|
||||||
HEAD /api/health 200 in 0ms
|
|
||||||
|
|
||||||
|
Before Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 8.8 KiB |
|
Before Width: | Height: | Size: 146 KiB |
|
Before Width: | Height: | Size: 178 KiB |
|
Before Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 172 KiB |
|
Before Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 108 KiB |
@@ -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);
|
||||||
@@ -210,7 +210,7 @@ export function CustomerOrderDetails({ order }: CustomerOrderDetailsProps) {
|
|||||||
<Badge
|
<Badge
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: order.group.color + "20",
|
backgroundColor: order.group.color + "20",
|
||||||
color: order.group.color,
|
color: order.group.color || undefined,
|
||||||
border: `1px solid ${order.group.color}30`
|
border: `1px solid ${order.group.color}30`
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -42,13 +42,14 @@ const customerOrderFormSchema = z.object({
|
|||||||
customerNotified: z.boolean().default(false),
|
customerNotified: z.boolean().default(false),
|
||||||
notes: z.string().optional(),
|
notes: z.string().optional(),
|
||||||
customerEmail: z.string().optional(),
|
customerEmail: z.string().optional(),
|
||||||
|
status: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
type CustomerOrderFormData = z.infer<typeof customerOrderFormSchema>;
|
type CustomerOrderFormData = z.infer<typeof customerOrderFormSchema>;
|
||||||
|
|
||||||
interface CustomerOrderFormProps {
|
interface CustomerOrderFormProps {
|
||||||
order?: CustomerOrderWithRelations;
|
order?: CustomerOrderWithRelations;
|
||||||
onSubmit: (data: CustomerOrderFormData) => void;
|
onSubmit: (data: Record<string, any>) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
}
|
}
|
||||||
@@ -113,7 +114,7 @@ export function CustomerOrderForm({
|
|||||||
quantity: order?.quantity || 1,
|
quantity: order?.quantity || 1,
|
||||||
supplierId: order?.supplierId || 1,
|
supplierId: order?.supplierId || 1,
|
||||||
status: "En attente de Commande", // Statut fixe
|
status: "En attente de Commande", // Statut fixe
|
||||||
deposit: order?.deposit || 0,
|
deposit: order?.deposit ? Number(order.deposit) : 0,
|
||||||
isPromotionalPrice: order?.isPromotionalPrice || false,
|
isPromotionalPrice: order?.isPromotionalPrice || false,
|
||||||
customerNotified: order?.customerNotified || false,
|
customerNotified: order?.customerNotified || false,
|
||||||
groupId: order?.groupId || getUserAssignedGroupId() || 1,
|
groupId: order?.groupId || getUserAssignedGroupId() || 1,
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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() || '';
|
||||||
@@ -273,7 +263,7 @@ export default function CreateDeliveryModal({
|
|||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className="w-3 h-3 rounded-full"
|
className="w-3 h-3 rounded-full"
|
||||||
style={{ backgroundColor: selectedGroup.color }}
|
style={{ backgroundColor: selectedGroup.color || undefined }}
|
||||||
/>
|
/>
|
||||||
<span className="font-medium">{selectedGroup.name}</span>
|
<span className="font-medium">{selectedGroup.name}</span>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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) => {
|
||||||
@@ -210,7 +190,7 @@ export default function CreateOrderModal({
|
|||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className="w-3 h-3 rounded-full"
|
className="w-3 h-3 rounded-full"
|
||||||
style={{ backgroundColor: selectedGroup.color }}
|
style={{ backgroundColor: selectedGroup.color || undefined }}
|
||||||
/>
|
/>
|
||||||
<span className="font-medium">{selectedGroup.name}</span>
|
<span className="font-medium">{selectedGroup.name}</span>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -178,7 +186,7 @@ export default function EditDeliveryModal({
|
|||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<div
|
<div
|
||||||
className="w-3 h-3 rounded-full"
|
className="w-3 h-3 rounded-full"
|
||||||
style={{ backgroundColor: group.color }}
|
style={{ backgroundColor: group.color || undefined }}
|
||||||
/>
|
/>
|
||||||
<span>{group.name}</span>
|
<span>{group.name}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ export default function EditOrderModal({
|
|||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<div
|
<div
|
||||||
className="w-3 h-3 rounded-full"
|
className="w-3 h-3 rounded-full"
|
||||||
style={{ backgroundColor: group.color }}
|
style={{ backgroundColor: group.color || undefined }}
|
||||||
/>
|
/>
|
||||||
<span>{group.name}</span>
|
<span>{group.name}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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?.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'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { format } from "date-fns";
|
import { format, type Locale } from "date-fns";
|
||||||
import { fr } from "date-fns/locale";
|
import { fr } from "date-fns/locale";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 +0,0 @@
|
|||||||
// Backup temporaire pour restaurer si nécessaire
|
|
||||||
@@ -1,778 +0,0 @@
|
|||||||
import { useState, useEffect } from "react";
|
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { safeFormat } from "@/lib/dateUtils";
|
|
||||||
import { useToast } from "@/hooks/use-toast";
|
|
||||||
import { apiRequest } from "@/lib/queryClient";
|
|
||||||
import { useStore } from "@/contexts/StoreContext";
|
|
||||||
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
|
||||||
import { usePermissions } from "@shared/permissions";
|
|
||||||
import { Search, Edit, FileText, Settings, Eye, AlertTriangle, X, Check, Trash2, Ban, ChevronLeft, ChevronRight } from "lucide-react";
|
|
||||||
import type { Supplier } from "@shared/schema";
|
|
||||||
|
|
||||||
export default function BLReconciliation() {
|
|
||||||
const { user } = useAuthUnified();
|
|
||||||
const { selectedStoreId } = useStore();
|
|
||||||
const { toast } = useToast();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const permissions = usePermissions(user?.role);
|
|
||||||
|
|
||||||
// Vérification des permissions d'accès au module
|
|
||||||
if (!permissions.canView('reconciliation')) {
|
|
||||||
return (
|
|
||||||
<div className="p-6">
|
|
||||||
<div className="bg-orange-50 border-l-4 border-orange-400 p-4">
|
|
||||||
<div className="flex">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<FileText className="h-5 w-5 text-orange-400" />
|
|
||||||
</div>
|
|
||||||
<div className="ml-3">
|
|
||||||
<p className="text-sm text-orange-700">
|
|
||||||
<strong>Accès restreint</strong><br />
|
|
||||||
Seuls les directeurs et administrateurs peuvent accéder au module de rapprochement BL/Factures.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState("manual");
|
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
|
||||||
const itemsPerPage = 20;
|
|
||||||
|
|
||||||
// Récupérer les fournisseurs pour la logique automatique
|
|
||||||
const { data: suppliers = [], isLoading: suppliersLoading } = useQuery<Supplier[]>({
|
|
||||||
queryKey: ['/api/suppliers'],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Récupérer les livraisons validées avec BL
|
|
||||||
const { data: deliveriesWithBL = [], isLoading } = useQuery({
|
|
||||||
queryKey: ['/api/deliveries/bl', selectedStoreId],
|
|
||||||
queryFn: async () => {
|
|
||||||
const params = new URLSearchParams({});
|
|
||||||
if (selectedStoreId && user?.role === 'admin') {
|
|
||||||
params.append('storeId', selectedStoreId.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(`/api/deliveries?${params.toString()}`, {
|
|
||||||
credentials: 'include'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to fetch deliveries');
|
|
||||||
}
|
|
||||||
|
|
||||||
const deliveries = await response.json();
|
|
||||||
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());
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Séparer les livraisons selon le mode de rapprochement du fournisseur
|
|
||||||
const manualReconciliationDeliveries = deliveriesWithBL.filter((delivery: any) => {
|
|
||||||
const supplier = suppliers.find((s: any) => s.id === delivery.supplierId);
|
|
||||||
return !supplier?.automaticReconciliation;
|
|
||||||
});
|
|
||||||
|
|
||||||
const automaticReconciliationDeliveries = deliveriesWithBL.filter((delivery: any) => {
|
|
||||||
const supplier = suppliers.find((s: any) => s.id === delivery.supplierId);
|
|
||||||
return supplier?.automaticReconciliation;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mutations pour les actions
|
|
||||||
const validateManualMutation = useMutation({
|
|
||||||
mutationFn: async (deliveryId: number) => {
|
|
||||||
return await apiRequest(`/api/deliveries/${deliveryId}`, "PUT", {
|
|
||||||
reconciled: true,
|
|
||||||
validatedAt: new Date().toISOString()
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
toast({
|
|
||||||
title: "Succès",
|
|
||||||
description: "Rapprochement validé avec succès",
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['/api/deliveries/bl'] });
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
toast({
|
|
||||||
title: "Erreur",
|
|
||||||
description: "Impossible de valider le rapprochement",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const devalidateMutation = useMutation({
|
|
||||||
mutationFn: async (deliveryId: number) => {
|
|
||||||
return await apiRequest(`/api/deliveries/${deliveryId}`, "PUT", {
|
|
||||||
reconciled: false,
|
|
||||||
validatedAt: null
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
toast({
|
|
||||||
title: "Succès",
|
|
||||||
description: "Rapprochement dévalidé avec succès",
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['/api/deliveries/bl'] });
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
toast({
|
|
||||||
title: "Erreur",
|
|
||||||
description: "Impossible de dévalider le rapprochement",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
|
||||||
mutationFn: async (deliveryId: number) => {
|
|
||||||
return await apiRequest(`/api/deliveries/${deliveryId}`, "DELETE");
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
toast({
|
|
||||||
title: "Succès",
|
|
||||||
description: "Ligne supprimée avec succès",
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['/api/deliveries/bl'] });
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
toast({
|
|
||||||
title: "Erreur",
|
|
||||||
description: "Impossible de supprimer la ligne",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fonctions de gestion des actions
|
|
||||||
const handleValidateManual = (deliveryId: number) => {
|
|
||||||
if (!permissions.canValidate('reconciliation')) {
|
|
||||||
toast({
|
|
||||||
title: "Accès refusé",
|
|
||||||
description: "Vous n'avez pas les permissions pour valider les rapprochements",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
validateManualMutation.mutate(deliveryId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDevalidate = (deliveryId: number) => {
|
|
||||||
if (!permissions.canEdit('reconciliation')) {
|
|
||||||
toast({
|
|
||||||
title: "Accès refusé",
|
|
||||||
description: "Seuls les administrateurs peuvent dévalider les rapprochements",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (confirm("Êtes-vous sûr de vouloir dévalider ce rapprochement ?")) {
|
|
||||||
devalidateMutation.mutate(deliveryId);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = (deliveryId: number) => {
|
|
||||||
if (!permissions.canDelete('reconciliation')) {
|
|
||||||
toast({
|
|
||||||
title: "Accès refusé",
|
|
||||||
description: "Seuls les administrateurs peuvent supprimer les lignes",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (confirm("Êtes-vous sûr de vouloir supprimer cette ligne ? Cette action est irréversible.")) {
|
|
||||||
deleteMutation.mutate(deliveryId);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Filtrage des livraisons par recherche
|
|
||||||
const filterDeliveries = (deliveries: any[]) => {
|
|
||||||
return deliveries.filter((delivery: any) => {
|
|
||||||
const searchLower = searchTerm.toLowerCase();
|
|
||||||
return (
|
|
||||||
delivery.supplier?.name?.toLowerCase().includes(searchLower) ||
|
|
||||||
delivery.blNumber?.toLowerCase().includes(searchLower) ||
|
|
||||||
delivery.invoiceReference?.toLowerCase().includes(searchLower)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const filteredManualDeliveries = filterDeliveries(manualReconciliationDeliveries);
|
|
||||||
const filteredAutomaticDeliveries = filterDeliveries(automaticReconciliationDeliveries);
|
|
||||||
|
|
||||||
// Pagination
|
|
||||||
const getCurrentPageData = (data: any[]) => {
|
|
||||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
|
||||||
const endIndex = startIndex + itemsPerPage;
|
|
||||||
return data.slice(startIndex, endIndex);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getTotalPages = (data: any[]) => {
|
|
||||||
return Math.ceil(data.length / itemsPerPage);
|
|
||||||
};
|
|
||||||
|
|
||||||
const currentManualData = getCurrentPageData(filteredManualDeliveries);
|
|
||||||
const currentAutomaticData = getCurrentPageData(filteredAutomaticDeliveries);
|
|
||||||
|
|
||||||
const totalManualPages = getTotalPages(filteredManualDeliveries);
|
|
||||||
const totalAutomaticPages = getTotalPages(filteredAutomaticDeliveries);
|
|
||||||
|
|
||||||
// Reset page when changing tabs or searching
|
|
||||||
useEffect(() => {
|
|
||||||
setCurrentPage(1);
|
|
||||||
}, [activeTab, searchTerm]);
|
|
||||||
|
|
||||||
// Vérifier si une ligne est dévalidée (pour la coloration)
|
|
||||||
const isDevalidated = (delivery: any) => {
|
|
||||||
return delivery.reconciled === false && delivery.validatedAt === null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Composant de pagination simplifiée
|
|
||||||
const NativePagination = ({ currentPage, totalPages, onPageChange, totalItems, itemsPerPage }: {
|
|
||||||
currentPage: number;
|
|
||||||
totalPages: number;
|
|
||||||
onPageChange: (page: number) => void;
|
|
||||||
totalItems: number;
|
|
||||||
itemsPerPage: number;
|
|
||||||
}) => {
|
|
||||||
if (totalPages <= 1) return null;
|
|
||||||
|
|
||||||
const startItem = (currentPage - 1) * itemsPerPage + 1;
|
|
||||||
const endItem = Math.min(currentPage * itemsPerPage, totalItems);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-between px-4 py-3 bg-white border-t border-gray-200">
|
|
||||||
<div className="flex items-center text-sm text-gray-700">
|
|
||||||
<span>
|
|
||||||
Affichage de {startItem} à {endItem} sur {totalItems} éléments
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<button
|
|
||||||
onClick={() => onPageChange(currentPage - 1)}
|
|
||||||
disabled={currentPage === 1}
|
|
||||||
className="px-3 py-1 text-sm border border-gray-300 rounded bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-1"
|
|
||||||
>
|
|
||||||
<ChevronLeft className="w-4 h-4" />
|
|
||||||
<span>Précédent</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<span className="px-3 py-1 text-sm bg-gray-100 rounded">
|
|
||||||
Page {currentPage} sur {totalPages}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => onPageChange(currentPage + 1)}
|
|
||||||
disabled={currentPage === totalPages}
|
|
||||||
className="px-3 py-1 text-sm border border-gray-300 rounded bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-1"
|
|
||||||
>
|
|
||||||
<span>Suivant</span>
|
|
||||||
<ChevronRight className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Badge native
|
|
||||||
const NativeBadge = ({ children, variant = "default", className = "" }: { children: React.ReactNode, variant?: string, className?: string }) => {
|
|
||||||
const baseClasses = "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium";
|
|
||||||
const variantClasses = {
|
|
||||||
default: "bg-blue-100 text-blue-800",
|
|
||||||
secondary: "bg-gray-100 text-gray-800",
|
|
||||||
destructive: "bg-red-100 text-red-800",
|
|
||||||
outline: "border border-gray-300 text-gray-700 bg-white"
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span className={`${baseClasses} ${variantClasses[variant as keyof typeof variantClasses] || variantClasses.default} ${className}`}>
|
|
||||||
{children}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Button native
|
|
||||||
const NativeButton = ({ children, onClick, disabled = false, variant = "default", size = "default", className = "", title = "" }: {
|
|
||||||
children: React.ReactNode,
|
|
||||||
onClick?: () => void,
|
|
||||||
disabled?: boolean,
|
|
||||||
variant?: string,
|
|
||||||
size?: string,
|
|
||||||
className?: string,
|
|
||||||
title?: string
|
|
||||||
}) => {
|
|
||||||
const baseClasses = "inline-flex items-center justify-center font-medium rounded transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2";
|
|
||||||
const variantClasses = {
|
|
||||||
default: "bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500",
|
|
||||||
ghost: "text-gray-600 hover:text-gray-900 hover:bg-gray-100",
|
|
||||||
outline: "border border-gray-300 bg-white text-gray-700 hover:bg-gray-50"
|
|
||||||
};
|
|
||||||
const sizeClasses = {
|
|
||||||
default: "px-4 py-2 text-sm",
|
|
||||||
sm: "px-2 py-1 text-xs"
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={onClick}
|
|
||||||
disabled={disabled}
|
|
||||||
title={title}
|
|
||||||
className={`${baseClasses} ${variantClasses[variant as keyof typeof variantClasses] || variantClasses.default} ${sizeClasses[size as keyof typeof sizeClasses] || sizeClasses.default} ${disabled ? 'opacity-50 cursor-not-allowed' : ''} ${className}`}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading || suppliersLoading) {
|
|
||||||
return (
|
|
||||||
<div className="p-6">
|
|
||||||
<div className="animate-pulse space-y-4">
|
|
||||||
<div className="h-8 bg-gray-200 rounded w-1/4"></div>
|
|
||||||
<div className="h-64 bg-gray-200 rounded"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-6 space-y-6">
|
|
||||||
{/* Header avec onglets */}
|
|
||||||
<div className="bg-white border-b border-gray-200 p-6 shadow-sm -m-6 mb-6">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-2xl font-semibold text-gray-900 flex items-center">
|
|
||||||
<FileText className="w-6 h-6 mr-3 text-blue-600" />
|
|
||||||
Rapprochement BL/Factures
|
|
||||||
</h2>
|
|
||||||
<p className="text-gray-600 mt-1">
|
|
||||||
Gestion des rapprochements manuels et automatiques
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-4">
|
|
||||||
<NativeBadge variant="outline" className="text-sm border border-gray-300">
|
|
||||||
{manualReconciliationDeliveries.length} rapprochements manuels
|
|
||||||
</NativeBadge>
|
|
||||||
<NativeBadge variant="outline" className="text-sm border border-gray-300 bg-blue-50">
|
|
||||||
{automaticReconciliationDeliveries.length} rapprochements automatiques
|
|
||||||
</NativeBadge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Onglets natifs */}
|
|
||||||
<div className="border-b border-gray-200">
|
|
||||||
<nav className="-mb-px flex space-x-8">
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab("manual")}
|
|
||||||
className={`py-2 px-1 border-b-2 font-medium text-sm flex items-center space-x-2 ${
|
|
||||||
activeTab === "manual"
|
|
||||||
? "border-blue-500 text-blue-600"
|
|
||||||
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Edit className="w-4 h-4" />
|
|
||||||
<span>Rapprochement Manuel</span>
|
|
||||||
<NativeBadge variant="secondary" className="ml-2">
|
|
||||||
{manualReconciliationDeliveries.length}
|
|
||||||
</NativeBadge>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab("automatic")}
|
|
||||||
className={`py-2 px-1 border-b-2 font-medium text-sm flex items-center space-x-2 ${
|
|
||||||
activeTab === "automatic"
|
|
||||||
? "border-blue-500 text-blue-600"
|
|
||||||
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Settings className="w-4 h-4" />
|
|
||||||
<span>Rapprochement Automatique</span>
|
|
||||||
<NativeBadge variant="secondary" className="ml-2">
|
|
||||||
{automaticReconciliationDeliveries.length}
|
|
||||||
</NativeBadge>
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filtre de recherche */}
|
|
||||||
<div className="bg-gray-50 border border-gray-200 p-4 rounded-lg">
|
|
||||||
<div className="relative max-w-md">
|
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Rechercher par fournisseur, BL ou facture..."
|
|
||||||
value={searchTerm}
|
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
|
||||||
className="pl-10 pr-4 py-2 w-full border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Contenu des onglets */}
|
|
||||||
{activeTab === "manual" && (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{filteredManualDeliveries.length === 0 ? (
|
|
||||||
<div className="text-center py-12">
|
|
||||||
<FileText className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
|
||||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
|
||||||
Aucun rapprochement manuel trouvé
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600">
|
|
||||||
Les livraisons de fournisseurs en mode manuel apparaîtront ici.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="bg-white border border-gray-200 shadow-lg overflow-hidden rounded-lg">
|
|
||||||
{/* Pagination du haut */}
|
|
||||||
<NativePagination
|
|
||||||
currentPage={currentPage}
|
|
||||||
totalPages={totalManualPages}
|
|
||||||
onPageChange={setCurrentPage}
|
|
||||||
totalItems={filteredManualDeliveries.length}
|
|
||||||
itemsPerPage={itemsPerPage}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="table-container">
|
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
|
||||||
<thead className="bg-gray-50 border-b border-gray-200">
|
|
||||||
<tr>
|
|
||||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Fournisseur</th>
|
|
||||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">N° BL</th>
|
|
||||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date Livr.</th>
|
|
||||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Montant BL</th>
|
|
||||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Ref. Facture</th>
|
|
||||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Montant Fact.</th>
|
|
||||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Statut</th>
|
|
||||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
|
||||||
{currentManualData.map((delivery: any) => (
|
|
||||||
<tr
|
|
||||||
key={delivery.id}
|
|
||||||
className={`hover:bg-gray-50 ${
|
|
||||||
delivery.reconciled ? 'bg-green-50' :
|
|
||||||
isDevalidated(delivery) ? 'bg-red-50' : ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<td className="px-3 py-2 text-sm">
|
|
||||||
<div className="font-medium text-gray-900 truncate max-w-32">
|
|
||||||
{delivery.supplier?.name}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 text-sm">
|
|
||||||
<div className="text-gray-900">
|
|
||||||
{delivery.blNumber || (
|
|
||||||
<span className="text-gray-400 italic text-xs">Non renseigné</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 text-sm">
|
|
||||||
<div className="text-gray-900">
|
|
||||||
{safeFormat(delivery.scheduledDate, 'dd/MM/yy')}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 text-sm">
|
|
||||||
<div className="font-medium text-gray-900">
|
|
||||||
{delivery.blAmount ?
|
|
||||||
`${parseFloat(delivery.blAmount).toFixed(2)}€` :
|
|
||||||
<span className="text-gray-400 italic text-xs">Non renseigné</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 text-sm">
|
|
||||||
<div className="text-gray-900">
|
|
||||||
{delivery.invoiceReference || (
|
|
||||||
<span className="text-gray-400 italic text-xs">Non renseigné</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 text-sm">
|
|
||||||
<div className="text-gray-900">
|
|
||||||
{delivery.invoiceAmount ?
|
|
||||||
`${parseFloat(delivery.invoiceAmount).toFixed(2)}€` :
|
|
||||||
<span className="text-gray-400 italic text-xs">Non renseigné</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 text-sm">
|
|
||||||
{delivery.reconciled ? (
|
|
||||||
<NativeBadge variant="default" className="bg-green-600 text-white">
|
|
||||||
Validé
|
|
||||||
</NativeBadge>
|
|
||||||
) : isDevalidated(delivery) ? (
|
|
||||||
<NativeBadge variant="destructive">
|
|
||||||
Dévalidé
|
|
||||||
</NativeBadge>
|
|
||||||
) : (
|
|
||||||
<NativeBadge variant="outline">
|
|
||||||
En attente
|
|
||||||
</NativeBadge>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 text-sm">
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
{permissions.canEdit('reconciliation') && (
|
|
||||||
<NativeButton
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="text-gray-600 hover:text-blue-600 p-1"
|
|
||||||
title="Modifier"
|
|
||||||
>
|
|
||||||
<Edit className="w-4 h-4" />
|
|
||||||
</NativeButton>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!delivery.reconciled && permissions.canValidate('reconciliation') && (
|
|
||||||
<NativeButton
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleValidateManual(delivery.id)}
|
|
||||||
disabled={validateManualMutation.isPending}
|
|
||||||
className="text-green-600 hover:text-green-700 p-1"
|
|
||||||
title="Valider"
|
|
||||||
>
|
|
||||||
<Check className="w-4 h-4" />
|
|
||||||
</NativeButton>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{delivery.reconciled && permissions.canEdit('reconciliation') && user?.role === 'admin' && (
|
|
||||||
<NativeButton
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleDevalidate(delivery.id)}
|
|
||||||
disabled={devalidateMutation.isPending}
|
|
||||||
className="text-orange-600 hover:text-orange-700 p-1"
|
|
||||||
title="Dévalider"
|
|
||||||
>
|
|
||||||
<Ban className="w-4 h-4" />
|
|
||||||
</NativeButton>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{permissions.canDelete('reconciliation') && (
|
|
||||||
<NativeButton
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleDelete(delivery.id)}
|
|
||||||
disabled={deleteMutation.isPending}
|
|
||||||
className="text-red-600 hover:text-red-700 p-1"
|
|
||||||
title="Supprimer"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
</NativeButton>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pagination du bas */}
|
|
||||||
<NativePagination
|
|
||||||
currentPage={currentPage}
|
|
||||||
totalPages={totalManualPages}
|
|
||||||
onPageChange={setCurrentPage}
|
|
||||||
totalItems={filteredManualDeliveries.length}
|
|
||||||
itemsPerPage={itemsPerPage}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === "automatic" && (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Message d'information */}
|
|
||||||
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
|
||||||
<div className="flex items-start space-x-3">
|
|
||||||
<AlertTriangle className="w-5 h-5 text-blue-600 mt-0.5" />
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-medium text-blue-900">Mode rapprochement automatique</h4>
|
|
||||||
<p className="text-sm text-blue-700 mt-1">
|
|
||||||
Les livraisons de fournisseurs en mode automatique sont validées automatiquement lorsqu'elles ont le statut "delivered" et un numéro de BL.
|
|
||||||
{permissions.canEdit('reconciliation') ? (
|
|
||||||
" Vous pouvez dévalider ces rapprochements si nécessaire."
|
|
||||||
) : (
|
|
||||||
" Seuls les directeurs et administrateurs peuvent modifier ces rapprochements."
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{filteredAutomaticDeliveries.length === 0 ? (
|
|
||||||
<div className="text-center py-12">
|
|
||||||
<Settings className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
|
||||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
|
||||||
Aucun rapprochement automatique trouvé
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600">
|
|
||||||
Les livraisons de fournisseurs en mode automatique apparaîtront ici.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="bg-white border border-gray-200 shadow-lg overflow-hidden rounded-lg">
|
|
||||||
{/* Pagination du haut */}
|
|
||||||
<NativePagination
|
|
||||||
currentPage={currentPage}
|
|
||||||
totalPages={totalAutomaticPages}
|
|
||||||
onPageChange={setCurrentPage}
|
|
||||||
totalItems={filteredAutomaticDeliveries.length}
|
|
||||||
itemsPerPage={itemsPerPage}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="table-container">
|
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
|
||||||
<thead className="bg-gray-50 border-b border-gray-200">
|
|
||||||
<tr>
|
|
||||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Fournisseur
|
|
||||||
<NativeBadge variant="secondary" className="ml-2 text-xs">AUTO</NativeBadge>
|
|
||||||
</th>
|
|
||||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">N° BL</th>
|
|
||||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date Livr.</th>
|
|
||||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date Valid.</th>
|
|
||||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Montant BL</th>
|
|
||||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Ref. Facture</th>
|
|
||||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Statut</th>
|
|
||||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
|
||||||
{currentAutomaticData.map((delivery: any) => (
|
|
||||||
<tr
|
|
||||||
key={delivery.id}
|
|
||||||
className={`hover:bg-gray-50 ${
|
|
||||||
isDevalidated(delivery) ? 'bg-red-50' : 'bg-green-50'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<td className="px-3 py-2 text-sm">
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<div className="font-medium text-gray-900 truncate max-w-32">
|
|
||||||
{delivery.supplier?.name}
|
|
||||||
</div>
|
|
||||||
<div className="w-3 h-3 bg-blue-500 rounded-full" title="Mode automatique" />
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 text-sm">
|
|
||||||
<div className="text-gray-900 font-medium">
|
|
||||||
{delivery.blNumber || '-'}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 text-sm">
|
|
||||||
<div className="text-gray-900">
|
|
||||||
{safeFormat(delivery.scheduledDate, 'dd/MM/yy')}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 text-sm">
|
|
||||||
<div className="text-gray-900">
|
|
||||||
{delivery.reconciled && delivery.validatedAt ?
|
|
||||||
safeFormat(delivery.validatedAt, 'dd/MM/yy') :
|
|
||||||
'-'
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 text-sm">
|
|
||||||
<div className="font-medium text-gray-900">
|
|
||||||
{delivery.blAmount ?
|
|
||||||
`${parseFloat(delivery.blAmount).toFixed(2)}€` :
|
|
||||||
'-'
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 text-sm">
|
|
||||||
<div className="text-gray-900">
|
|
||||||
{delivery.invoiceReference || '-'}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 text-sm">
|
|
||||||
{isDevalidated(delivery) ? (
|
|
||||||
<NativeBadge variant="destructive">
|
|
||||||
Dévalidé
|
|
||||||
</NativeBadge>
|
|
||||||
) : (
|
|
||||||
<NativeBadge variant="default" className="bg-green-600 text-white">
|
|
||||||
Auto-validé
|
|
||||||
</NativeBadge>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 text-sm">
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
{isDevalidated(delivery) && permissions.canEdit('reconciliation') && (
|
|
||||||
<>
|
|
||||||
<NativeButton
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="text-gray-600 hover:text-blue-600 p-1"
|
|
||||||
title="Modifier"
|
|
||||||
>
|
|
||||||
<Edit className="w-4 h-4" />
|
|
||||||
</NativeButton>
|
|
||||||
<NativeButton
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleValidateManual(delivery.id)}
|
|
||||||
disabled={validateManualMutation.isPending}
|
|
||||||
className="text-green-600 hover:text-green-700 p-1"
|
|
||||||
title="Valider"
|
|
||||||
>
|
|
||||||
<Check className="w-4 h-4" />
|
|
||||||
</NativeButton>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isDevalidated(delivery) && permissions.canEdit('reconciliation') && (
|
|
||||||
<NativeButton
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleDevalidate(delivery.id)}
|
|
||||||
disabled={devalidateMutation.isPending}
|
|
||||||
className="text-orange-600 hover:text-orange-700 p-1"
|
|
||||||
title="Dévalider"
|
|
||||||
>
|
|
||||||
<Ban className="w-4 h-4" />
|
|
||||||
</NativeButton>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{permissions.canDelete('reconciliation') && (
|
|
||||||
<NativeButton
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleDelete(delivery.id)}
|
|
||||||
disabled={deleteMutation.isPending}
|
|
||||||
className="text-red-600 hover:text-red-700 p-1"
|
|
||||||
title="Supprimer"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
</NativeButton>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pagination du bas */}
|
|
||||||
<NativePagination
|
|
||||||
currentPage={currentPage}
|
|
||||||
totalPages={totalAutomaticPages}
|
|
||||||
onPageChange={setCurrentPage}
|
|
||||||
totalItems={filteredAutomaticDeliveries.length}
|
|
||||||
itemsPerPage={itemsPerPage}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</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>
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
import type { Supplier, Contact, Group } from "@shared/schema";
|
import type { Supplier, Contact, Group } from "@shared/schema";
|
||||||
|
|
||||||
const CAN_EDIT_ROLES = ["admin", "directeur", "manager"];
|
const CAN_EDIT_ROLES = ["admin", "directeur", "manager"];
|
||||||
|
const CAN_EDIT_SUPPLIER = ["admin", "directeur"];
|
||||||
|
|
||||||
export default function Contacts() {
|
export default function Contacts() {
|
||||||
const { user } = useAuthUnified();
|
const { user } = useAuthUnified();
|
||||||
@@ -32,6 +33,7 @@ export default function Contacts() {
|
|||||||
const { stores, selectedStoreId } = useStore();
|
const { stores, selectedStoreId } = useStore();
|
||||||
|
|
||||||
const canEdit = user?.role && CAN_EDIT_ROLES.includes(user.role);
|
const canEdit = user?.role && CAN_EDIT_ROLES.includes(user.role);
|
||||||
|
const canEditSupplier = user?.role && CAN_EDIT_SUPPLIER.includes(user.role);
|
||||||
const isAdmin = user?.role === "admin";
|
const isAdmin = user?.role === "admin";
|
||||||
|
|
||||||
// Pour les admins, permettre de choisir un magasin
|
// Pour les admins, permettre de choisir un magasin
|
||||||
@@ -41,33 +43,40 @@ export default function Contacts() {
|
|||||||
const [supplierSearch, setSupplierSearch] = useState("");
|
const [supplierSearch, setSupplierSearch] = useState("");
|
||||||
const [contactSearch, setContactSearch] = useState("");
|
const [contactSearch, setContactSearch] = useState("");
|
||||||
|
|
||||||
|
// Modal contacts libres
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
const [selectedContact, setSelectedContact] = useState<Contact | null>(null);
|
const [selectedContact, setSelectedContact] = useState<Contact | null>(null);
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
groupId: effectiveGroupId ?? 0,
|
groupId: effectiveGroupId ?? 0,
|
||||||
name: "",
|
name: "",
|
||||||
|
company: "",
|
||||||
role: "",
|
role: "",
|
||||||
phone: "",
|
phone: "",
|
||||||
email: "",
|
email: "",
|
||||||
notes: "",
|
notes: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Modal coordonnées fournisseur
|
||||||
|
const [showSupplierModal, setShowSupplierModal] = useState(false);
|
||||||
|
const [selectedSupplier, setSelectedSupplier] = useState<Supplier | null>(null);
|
||||||
|
const [supplierFormData, setSupplierFormData] = useState({
|
||||||
|
contact: "",
|
||||||
|
phone: "",
|
||||||
|
email: "",
|
||||||
|
});
|
||||||
|
|
||||||
const { data: suppliers = [] } = useQuery<Supplier[]>({
|
const { data: suppliers = [] } = useQuery<Supplier[]>({
|
||||||
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({
|
||||||
@@ -105,11 +114,25 @@ export default function Contacts() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const updateSupplierMutation = useMutation({
|
||||||
|
mutationFn: (data: any) => apiRequest(`/api/suppliers/${selectedSupplier?.id}`, "PUT", data),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({ title: "Succès", description: "Coordonnées fournisseur mises à jour" });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["/api/suppliers"] });
|
||||||
|
setShowSupplierModal(false);
|
||||||
|
setSelectedSupplier(null);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast({ title: "Erreur", description: "Impossible de modifier le fournisseur", variant: "destructive" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const openCreate = () => {
|
const openCreate = () => {
|
||||||
setSelectedContact(null);
|
setSelectedContact(null);
|
||||||
setFormData({
|
setFormData({
|
||||||
groupId: effectiveGroupId ?? 0,
|
groupId: effectiveGroupId ?? 0,
|
||||||
name: "",
|
name: "",
|
||||||
|
company: "",
|
||||||
role: "",
|
role: "",
|
||||||
phone: "",
|
phone: "",
|
||||||
email: "",
|
email: "",
|
||||||
@@ -123,6 +146,7 @@ export default function Contacts() {
|
|||||||
setFormData({
|
setFormData({
|
||||||
groupId: contact.groupId,
|
groupId: contact.groupId,
|
||||||
name: contact.name,
|
name: contact.name,
|
||||||
|
company: (contact as any).company || "",
|
||||||
role: contact.role || "",
|
role: contact.role || "",
|
||||||
phone: contact.phone || "",
|
phone: contact.phone || "",
|
||||||
email: contact.email || "",
|
email: contact.email || "",
|
||||||
@@ -152,6 +176,16 @@ export default function Contacts() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openEditSupplier = (supplier: Supplier) => {
|
||||||
|
setSelectedSupplier(supplier);
|
||||||
|
setSupplierFormData({
|
||||||
|
contact: supplier.contact || "",
|
||||||
|
phone: supplier.phone || "",
|
||||||
|
email: (supplier as any).email || "",
|
||||||
|
});
|
||||||
|
setShowSupplierModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
const filteredSuppliers = suppliers.filter((s) =>
|
const filteredSuppliers = suppliers.filter((s) =>
|
||||||
s.name.toLowerCase().includes(supplierSearch.toLowerCase()) ||
|
s.name.toLowerCase().includes(supplierSearch.toLowerCase()) ||
|
||||||
(s.contact || "").toLowerCase().includes(supplierSearch.toLowerCase())
|
(s.contact || "").toLowerCase().includes(supplierSearch.toLowerCase())
|
||||||
@@ -260,6 +294,11 @@ export default function Contacts() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{canEditSupplier && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => openEditSupplier(supplier)}>
|
||||||
|
<Edit className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 space-y-1 pl-12">
|
<div className="mt-3 space-y-1 pl-12">
|
||||||
{supplier.phone && (
|
{supplier.phone && (
|
||||||
@@ -344,6 +383,9 @@ export default function Contacts() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold text-gray-900">{contact.name}</p>
|
<p className="font-semibold text-gray-900">{contact.name}</p>
|
||||||
|
{(contact as any).company && (
|
||||||
|
<p className="text-sm font-medium text-gray-700">{(contact as any).company}</p>
|
||||||
|
)}
|
||||||
{contact.role && (
|
{contact.role && (
|
||||||
<p className="text-sm text-gray-500">{contact.role}</p>
|
<p className="text-sm text-gray-500">{contact.role}</p>
|
||||||
)}
|
)}
|
||||||
@@ -445,6 +487,16 @@ export default function Contacts() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="c-company">Entreprise</Label>
|
||||||
|
<Input
|
||||||
|
id="c-company"
|
||||||
|
value={formData.company}
|
||||||
|
onChange={(e) => setFormData((p) => ({ ...p, company: e.target.value }))}
|
||||||
|
placeholder="Nom de l'entreprise"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="c-role">Fonction / Rôle</Label>
|
<Label htmlFor="c-role">Fonction / Rôle</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -505,6 +557,56 @@ export default function Contacts() {
|
|||||||
</form>
|
</form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Modal coordonnées fournisseur */}
|
||||||
|
<Dialog open={showSupplierModal} onOpenChange={() => { setShowSupplierModal(false); setSelectedSupplier(null); }}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Coordonnées — {selectedSupplier?.name}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => { e.preventDefault(); updateSupplierMutation.mutate(supplierFormData); }}
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="s-contact">Nom du contact</Label>
|
||||||
|
<Input
|
||||||
|
id="s-contact"
|
||||||
|
value={supplierFormData.contact}
|
||||||
|
onChange={(e) => setSupplierFormData((p) => ({ ...p, contact: e.target.value }))}
|
||||||
|
placeholder="Nom du contact chez le fournisseur"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="s-phone">Téléphone</Label>
|
||||||
|
<Input
|
||||||
|
id="s-phone"
|
||||||
|
value={supplierFormData.phone}
|
||||||
|
onChange={(e) => setSupplierFormData((p) => ({ ...p, phone: e.target.value }))}
|
||||||
|
placeholder="Numéro de téléphone"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="s-email">Email</Label>
|
||||||
|
<Input
|
||||||
|
id="s-email"
|
||||||
|
type="email"
|
||||||
|
value={supplierFormData.email}
|
||||||
|
onChange={(e) => setSupplierFormData((p) => ({ ...p, email: e.target.value }))}
|
||||||
|
placeholder="Adresse email"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-3 pt-2">
|
||||||
|
<Button type="button" variant="outline" onClick={() => { setShowSupplierModal(false); setSelectedSupplier(null); }}>
|
||||||
|
Annuler
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={updateSupplierMutation.isPending} className="bg-primary hover:bg-blue-700">
|
||||||
|
{updateSupplierMutation.isPending ? "Enregistrement..." : "Enregistrer"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -61,7 +69,7 @@ export default function DlcPage() {
|
|||||||
}, [searchTerm]);
|
}, [searchTerm]);
|
||||||
|
|
||||||
// Fetch stores/groups - optimized cache
|
// Fetch stores/groups - optimized cache
|
||||||
const { data: stores = [] } = useQuery({
|
const { data: stores = [] } = useQuery<any[]>({
|
||||||
queryKey: ["/api/groups"],
|
queryKey: ["/api/groups"],
|
||||||
enabled: !authLoading,
|
enabled: !authLoading,
|
||||||
staleTime: 5 * 60 * 1000, // 5 minutes cache
|
staleTime: 5 * 60 * 1000, // 5 minutes cache
|
||||||
@@ -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,36 +365,21 @@ 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:", {
|
const dlcData: any = {
|
||||||
userRole: user?.role,
|
|
||||||
selectedStoreId,
|
|
||||||
userGroups: user?.userGroups?.map(ug => ({groupId: ug.groupId, groupName: ug.group?.name})),
|
|
||||||
availableStores: stores.map(s => ({id: s.id, name: s.name})),
|
|
||||||
userGroupsRaw: user?.userGroups,
|
|
||||||
firstUserGroup: user?.userGroups?.[0],
|
|
||||||
finalGroupId: groupId,
|
|
||||||
logicPath: !groupId ? 'need-fallback' : 'already-set'
|
|
||||||
});
|
|
||||||
|
|
||||||
const dlcData: InsertDlcProduct = {
|
|
||||||
...data,
|
...data,
|
||||||
name: data.productName, // Copier productName vers name (requis dans la DB)
|
name: data.productName, // Copier productName vers name (requis dans la DB)
|
||||||
expiryDate: dlcDate,
|
expiryDate: dlcDate as any,
|
||||||
quantity: 1, // Valeur par défaut
|
quantity: 1, // Valeur par défaut
|
||||||
unit: "unité", // Valeur par défaut
|
unit: "unité", // Valeur par défaut
|
||||||
location: "Magasin", // Valeur par défaut
|
location: "Magasin", // Valeur par défaut
|
||||||
@@ -377,7 +397,7 @@ export default function DlcPage() {
|
|||||||
const handleEdit = (product: DlcProductWithRelations) => {
|
const handleEdit = (product: DlcProductWithRelations) => {
|
||||||
setEditingProduct(product);
|
setEditingProduct(product);
|
||||||
form.reset({
|
form.reset({
|
||||||
productName: product.productName,
|
productName: product.productName as string,
|
||||||
gencode: product.gencode || "",
|
gencode: product.gencode || "",
|
||||||
dlcDate: product.expiryDate ? format(new Date(product.expiryDate), "yyyy-MM-dd") : "",
|
dlcDate: product.expiryDate ? format(new Date(product.expiryDate), "yyyy-MM-dd") : "",
|
||||||
dateType: product.dateType as "dlc" | "ddm" | "dluo",
|
dateType: product.dateType as "dlc" | "ddm" | "dluo",
|
||||||
@@ -526,7 +546,7 @@ export default function DlcPage() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
${expiringSoon.map(product => {
|
${expiringSoon.map((product: any) => {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const expiry = new Date(product.dlcDate || new Date());
|
const expiry = new Date(product.dlcDate || new Date());
|
||||||
const diffDays = Math.ceil((expiry.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
|
const diffDays = Math.ceil((expiry.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
|
||||||
@@ -585,7 +605,7 @@ export default function DlcPage() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
${expired.map(product => {
|
${expired.map((product: any) => {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const expiry = new Date(product.dlcDate || new Date());
|
const expiry = new Date(product.dlcDate || new Date());
|
||||||
const diffDays = Math.ceil((today.getTime() - expiry.getTime()) / (1000 * 60 * 60 * 24));
|
const diffDays = Math.ceil((today.getTime() - expiry.getTime()) / (1000 * 60 * 60 * 24));
|
||||||
@@ -615,19 +635,19 @@ 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(() => {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const expiringSoon = filteredProducts.filter(product => {
|
const expiringSoon = filteredProducts.filter((product: any) => {
|
||||||
if (product.status === 'valides') return false;
|
if (product.status === 'valides') return false;
|
||||||
const expiryDate = new Date(product.dlcDate || new Date());
|
const expiryDate = new Date(product.dlcDate || new Date());
|
||||||
const diffDays = Math.ceil((expiryDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
|
const diffDays = Math.ceil((expiryDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
|
||||||
return diffDays <= 15 && diffDays > 0;
|
return diffDays <= 15 && diffDays > 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
const expired = filteredProducts.filter(product => {
|
const expired = filteredProducts.filter((product: any) => {
|
||||||
if (product.status === 'valides') return false;
|
if (product.status === 'valides') return false;
|
||||||
const expiryDate = new Date(product.dlcDate || new Date());
|
const expiryDate = new Date(product.dlcDate || new Date());
|
||||||
return expiryDate <= today;
|
return expiryDate <= today;
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ 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";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
@@ -18,7 +19,7 @@ import {
|
|||||||
Truck,
|
Truck,
|
||||||
Palette
|
Palette
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { Group, NocodbConfig } from "@shared/schema";
|
import type { EntityActivityStats, Group, NocodbConfig } from "@shared/schema";
|
||||||
|
|
||||||
const colorOptions = [
|
const colorOptions = [
|
||||||
{ value: '#1976D2', label: 'Bleu' },
|
{ value: '#1976D2', label: 'Bleu' },
|
||||||
@@ -33,6 +34,38 @@ const colorOptions = [
|
|||||||
{ value: '#00796B', label: 'Sarcelle' },
|
{ value: '#00796B', label: 'Sarcelle' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Taille maximale du logo magasin : il est encodé en base64 dans la fiche
|
||||||
|
// et joint à chaque mail, un fichier lourd alourdirait tous les envois.
|
||||||
|
const MAX_LOGO_KB = 200;
|
||||||
|
|
||||||
|
// Valeurs par défaut du formulaire magasin (création et réinitialisations)
|
||||||
|
const EMPTY_GROUP_FORM = {
|
||||||
|
name: "",
|
||||||
|
color: "#1976D2",
|
||||||
|
nocodbConfigId: "",
|
||||||
|
nocodbTableName: "",
|
||||||
|
invoiceColumnName: "",
|
||||||
|
nocodbBlColumnName: "",
|
||||||
|
nocodbAmountColumnName: "",
|
||||||
|
nocodbInvoiceAmountTTCColumnName: "",
|
||||||
|
nocodbSupplierColumnName: "",
|
||||||
|
nocodbDueDateColumnName: "",
|
||||||
|
webhookUrl: "",
|
||||||
|
// Coordonnées reprises dans la signature des mails fournisseurs
|
||||||
|
address: "",
|
||||||
|
phone: "",
|
||||||
|
logo: "",
|
||||||
|
// Configuration SMTP propre au magasin
|
||||||
|
smtpEnabled: false,
|
||||||
|
smtpHost: "",
|
||||||
|
smtpPort: "",
|
||||||
|
smtpSecure: false,
|
||||||
|
smtpUser: "",
|
||||||
|
smtpPassword: "",
|
||||||
|
smtpSenderEmail: "",
|
||||||
|
smtpSenderName: "",
|
||||||
|
};
|
||||||
|
|
||||||
export default function Groups() {
|
export default function Groups() {
|
||||||
const { user } = useAuthUnified();
|
const { user } = useAuthUnified();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@@ -42,55 +75,33 @@ export default function Groups() {
|
|||||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
const [showEditModal, setShowEditModal] = useState(false);
|
const [showEditModal, setShowEditModal] = useState(false);
|
||||||
const [selectedGroup, setSelectedGroup] = useState<Group | null>(null);
|
const [selectedGroup, setSelectedGroup] = useState<Group | null>(null);
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({ ...EMPTY_GROUP_FORM });
|
||||||
name: "",
|
|
||||||
color: "#1976D2",
|
|
||||||
nocodbConfigId: "",
|
|
||||||
nocodbTableName: "",
|
|
||||||
invoiceColumnName: "",
|
|
||||||
nocodbBlColumnName: "",
|
|
||||||
nocodbAmountColumnName: "",
|
|
||||||
nocodbInvoiceAmountTTCColumnName: "",
|
|
||||||
nocodbSupplierColumnName: "",
|
|
||||||
nocodbDueDateColumnName: "",
|
|
||||||
webhookUrl: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: groups = [], isLoading } = useQuery<Group[]>({
|
const { data: groups = [], isLoading } = useQuery<Group[]>({
|
||||||
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
|
||||||
const nocodbConfigs = Array.isArray(rawNocodbConfigs) ? rawNocodbConfigs : [];
|
const nocodbConfigs = Array.isArray(rawNocodbConfigs) ? rawNocodbConfigs : [];
|
||||||
|
|
||||||
console.log('🔍 Groups NocoDB Debug:', {
|
// Comptages agrégés en base : la page n'affiche que des totaux, inutile de
|
||||||
rawNocodbConfigs,
|
// rapatrier l'historique complet des commandes et des livraisons.
|
||||||
rawType: typeof rawNocodbConfigs,
|
const { data: groupStats = [] } = useQuery<EntityActivityStats[]>({
|
||||||
nocodbConfigs,
|
queryKey: ['/api/stats/by-group'],
|
||||||
configsType: typeof nocodbConfigs,
|
|
||||||
isArray: Array.isArray(rawNocodbConfigs),
|
|
||||||
length: nocodbConfigs.length,
|
|
||||||
environment: window.location.hostname
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: orders = [] } = useQuery({
|
|
||||||
queryKey: ['/api/orders'],
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: deliveries = [] } = useQuery({
|
|
||||||
queryKey: ['/api/deliveries'],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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({
|
||||||
@@ -99,19 +110,7 @@ export default function Groups() {
|
|||||||
});
|
});
|
||||||
queryClient.invalidateQueries({ queryKey: ['/api/groups'] });
|
queryClient.invalidateQueries({ queryKey: ['/api/groups'] });
|
||||||
setShowCreateModal(false);
|
setShowCreateModal(false);
|
||||||
setFormData({
|
setFormData({ ...EMPTY_GROUP_FORM });
|
||||||
name: "",
|
|
||||||
color: "#1976D2",
|
|
||||||
nocodbConfigId: "",
|
|
||||||
nocodbTableName: "",
|
|
||||||
invoiceColumnName: "",
|
|
||||||
nocodbBlColumnName: "",
|
|
||||||
nocodbAmountColumnName: "",
|
|
||||||
nocodbInvoiceAmountTTCColumnName: "",
|
|
||||||
nocodbSupplierColumnName: "",
|
|
||||||
nocodbDueDateColumnName: "",
|
|
||||||
webhookUrl: "",
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
if (isUnauthorizedError(error)) {
|
if (isUnauthorizedError(error)) {
|
||||||
@@ -145,19 +144,7 @@ export default function Groups() {
|
|||||||
queryClient.invalidateQueries({ queryKey: ['/api/groups'] });
|
queryClient.invalidateQueries({ queryKey: ['/api/groups'] });
|
||||||
setShowEditModal(false);
|
setShowEditModal(false);
|
||||||
setSelectedGroup(null);
|
setSelectedGroup(null);
|
||||||
setFormData({
|
setFormData({ ...EMPTY_GROUP_FORM });
|
||||||
name: "",
|
|
||||||
color: "#1976D2",
|
|
||||||
nocodbConfigId: "",
|
|
||||||
nocodbTableName: "",
|
|
||||||
invoiceColumnName: "",
|
|
||||||
nocodbBlColumnName: "",
|
|
||||||
nocodbAmountColumnName: "",
|
|
||||||
nocodbInvoiceAmountTTCColumnName: "",
|
|
||||||
nocodbSupplierColumnName: "",
|
|
||||||
nocodbDueDateColumnName: "",
|
|
||||||
webhookUrl: "",
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
if (isUnauthorizedError(error)) {
|
if (isUnauthorizedError(error)) {
|
||||||
@@ -179,6 +166,53 @@ export default function Groups() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Test de la connexion SMTP du magasin (aucun message envoyé)
|
||||||
|
const testSmtpMutation = useMutation({
|
||||||
|
mutationFn: async (groupId: number) => {
|
||||||
|
return await apiRequest(`/api/groups/${groupId}/test-smtp`, 'POST');
|
||||||
|
},
|
||||||
|
onSuccess: (result: any) => {
|
||||||
|
toast({
|
||||||
|
title: "Connexion réussie",
|
||||||
|
description: result?.message || "Le serveur SMTP répond correctement",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast({
|
||||||
|
title: "Échec de la connexion",
|
||||||
|
description: error?.message || "Impossible de joindre le serveur SMTP",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Lecture du logo en data URI : stocké tel quel sur la fiche magasin
|
||||||
|
const handleLogoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
if (file.size > MAX_LOGO_KB * 1024) {
|
||||||
|
toast({
|
||||||
|
title: "Logo trop volumineux",
|
||||||
|
description: `Le fichier fait ${Math.round(file.size / 1024)} Ko. Maximum autorisé : ${MAX_LOGO_KB} Ko.`,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
e.target.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => handleChange('logo', String(reader.result || ''));
|
||||||
|
reader.onerror = () => {
|
||||||
|
toast({
|
||||||
|
title: "Lecture impossible",
|
||||||
|
description: "Le fichier n'a pas pu être lu",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
};
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: async (id: number) => {
|
mutationFn: async (id: number) => {
|
||||||
await apiRequest(`/api/groups/${id}`, "DELETE");
|
await apiRequest(`/api/groups/${id}`, "DELETE");
|
||||||
@@ -214,59 +248,20 @@ export default function Groups() {
|
|||||||
group.name.toLowerCase().includes(searchTerm.toLowerCase())
|
group.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
) : [];
|
) : [];
|
||||||
|
|
||||||
console.log('🏪 Groups Debug:', {
|
|
||||||
groups: Array.isArray(groups) ? groups.length : 'NOT_ARRAY',
|
|
||||||
orders: Array.isArray(orders) ? orders.length : 'NOT_ARRAY',
|
|
||||||
deliveries: Array.isArray(deliveries) ? deliveries.length : 'NOT_ARRAY',
|
|
||||||
groupsData: groups?.slice(0, 2),
|
|
||||||
ordersData: orders?.slice(0, 2),
|
|
||||||
deliveriesData: deliveries?.slice(0, 2)
|
|
||||||
});
|
|
||||||
|
|
||||||
const getGroupStats = (groupId: number) => {
|
const getGroupStats = (groupId: number) => {
|
||||||
const groupOrders = Array.isArray(orders) ? orders.filter(order => {
|
const stats = Array.isArray(groupStats)
|
||||||
const match = order.groupId === groupId;
|
? groupStats.find(s => s.id === groupId)
|
||||||
if (match) console.log(`🎯 Order ${order.id} matches group ${groupId}`, order);
|
: undefined;
|
||||||
return match;
|
|
||||||
}) : [];
|
|
||||||
|
|
||||||
const groupDeliveries = Array.isArray(deliveries) ? deliveries.filter(delivery => {
|
|
||||||
const match = delivery.groupId === groupId;
|
|
||||||
if (match) console.log(`🎯 Delivery ${delivery.id} matches group ${groupId}`, delivery);
|
|
||||||
return match;
|
|
||||||
}) : [];
|
|
||||||
|
|
||||||
console.log(`🏪 Group ${groupId} stats:`, {
|
|
||||||
groupOrders: groupOrders.length,
|
|
||||||
groupDeliveries: groupDeliveries.length,
|
|
||||||
delivered: groupDeliveries.filter(d => d.status === 'delivered').length,
|
|
||||||
ordersSample: groupOrders.slice(0, 2),
|
|
||||||
deliveriesSample: groupDeliveries.slice(0, 2),
|
|
||||||
allOrdersCount: orders?.length,
|
|
||||||
allDeliveriesCount: deliveries?.length
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
orders: groupOrders.length,
|
orders: stats?.orders ?? 0,
|
||||||
deliveries: groupDeliveries.length,
|
deliveries: stats?.deliveries ?? 0,
|
||||||
delivered: groupDeliveries.filter(d => d.status === 'delivered').length,
|
delivered: stats?.delivered ?? 0,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreate = () => {
|
const handleCreate = () => {
|
||||||
setFormData({
|
setFormData({ ...EMPTY_GROUP_FORM });
|
||||||
name: "",
|
|
||||||
color: "#1976D2",
|
|
||||||
nocodbConfigId: "",
|
|
||||||
nocodbTableName: "",
|
|
||||||
invoiceColumnName: "",
|
|
||||||
nocodbBlColumnName: "",
|
|
||||||
nocodbAmountColumnName: "",
|
|
||||||
nocodbInvoiceAmountTTCColumnName: "",
|
|
||||||
nocodbSupplierColumnName: "",
|
|
||||||
nocodbDueDateColumnName: "",
|
|
||||||
webhookUrl: "",
|
|
||||||
});
|
|
||||||
setShowCreateModal(true);
|
setShowCreateModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -284,6 +279,18 @@ export default function Groups() {
|
|||||||
nocodbSupplierColumnName: group.nocodbSupplierColumnName || "",
|
nocodbSupplierColumnName: group.nocodbSupplierColumnName || "",
|
||||||
nocodbDueDateColumnName: group.nocodbDueDateColumnName || "",
|
nocodbDueDateColumnName: group.nocodbDueDateColumnName || "",
|
||||||
webhookUrl: group.webhookUrl || "",
|
webhookUrl: group.webhookUrl || "",
|
||||||
|
address: (group as any).address || "",
|
||||||
|
phone: (group as any).phone || "",
|
||||||
|
logo: (group as any).logo || "",
|
||||||
|
smtpEnabled: Boolean((group as any).smtpEnabled),
|
||||||
|
smtpHost: (group as any).smtpHost || "",
|
||||||
|
smtpPort: (group as any).smtpPort?.toString() || "",
|
||||||
|
smtpSecure: Boolean((group as any).smtpSecure),
|
||||||
|
smtpUser: (group as any).smtpUser || "",
|
||||||
|
// Jamais renvoyé par l'API : vide = mot de passe inchangé
|
||||||
|
smtpPassword: "",
|
||||||
|
smtpSenderEmail: (group as any).smtpSenderEmail || "",
|
||||||
|
smtpSenderName: (group as any).smtpSenderName || "",
|
||||||
});
|
});
|
||||||
setShowEditModal(true);
|
setShowEditModal(true);
|
||||||
};
|
};
|
||||||
@@ -318,11 +325,17 @@ export default function Groups() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Prepare data with proper type conversion
|
// Prepare data with proper type conversion
|
||||||
const submitData = {
|
const submitData: any = {
|
||||||
...formData,
|
...formData,
|
||||||
nocodbConfigId: formData.nocodbConfigId ? parseInt(formData.nocodbConfigId) : null,
|
nocodbConfigId: formData.nocodbConfigId ? parseInt(formData.nocodbConfigId) : null,
|
||||||
|
smtpPort: formData.smtpPort ? parseInt(formData.smtpPort) : null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Mot de passe laissé vide = on conserve celui déjà enregistré
|
||||||
|
if (!formData.smtpPassword) {
|
||||||
|
delete submitData.smtpPassword;
|
||||||
|
}
|
||||||
|
|
||||||
if (selectedGroup) {
|
if (selectedGroup) {
|
||||||
updateMutation.mutate(submitData);
|
updateMutation.mutate(submitData);
|
||||||
} else {
|
} else {
|
||||||
@@ -330,7 +343,7 @@ export default function Groups() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleChange = (field: string, value: string) => {
|
const handleChange = (field: string, value: string | boolean) => {
|
||||||
setFormData(prev => ({ ...prev, [field]: value }));
|
setFormData(prev => ({ ...prev, [field]: value }));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -413,7 +426,7 @@ export default function Groups() {
|
|||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div
|
<div
|
||||||
className="w-10 h-10 flex items-center justify-center mr-3"
|
className="w-10 h-10 flex items-center justify-center mr-3"
|
||||||
style={{ backgroundColor: group.color }}
|
style={{ backgroundColor: group.color || undefined }}
|
||||||
>
|
>
|
||||||
<Users className="w-5 h-5 text-white" />
|
<Users className="w-5 h-5 text-white" />
|
||||||
</div>
|
</div>
|
||||||
@@ -448,7 +461,7 @@ export default function Groups() {
|
|||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div
|
<div
|
||||||
className="w-4 h-4 rounded mr-2"
|
className="w-4 h-4 rounded mr-2"
|
||||||
style={{ backgroundColor: group.color }}
|
style={{ backgroundColor: group.color || undefined }}
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-gray-600">{group.color}</span>
|
<span className="text-sm text-gray-600">{group.color}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -492,16 +505,9 @@ export default function Groups() {
|
|||||||
setShowCreateModal(false);
|
setShowCreateModal(false);
|
||||||
setShowEditModal(false);
|
setShowEditModal(false);
|
||||||
setSelectedGroup(null);
|
setSelectedGroup(null);
|
||||||
setFormData({
|
setFormData({ ...EMPTY_GROUP_FORM, invoiceColumnName: "Ref Facture" });
|
||||||
name: "",
|
|
||||||
color: "#1976D2",
|
|
||||||
nocodbConfigId: "",
|
|
||||||
nocodbTableId: "",
|
|
||||||
nocodbTableName: "",
|
|
||||||
invoiceColumnName: "Ref Facture",
|
|
||||||
});
|
|
||||||
}}>
|
}}>
|
||||||
<DialogContent className="sm:max-w-md" aria-describedby="group-modal-description">
|
<DialogContent className="sm:max-w-4xl max-h-[90vh] overflow-y-auto" aria-describedby="group-modal-description">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
{selectedGroup ? 'Modifier' : 'Nouveau'} Groupe
|
{selectedGroup ? 'Modifier' : 'Nouveau'} Groupe
|
||||||
@@ -512,6 +518,9 @@ export default function Groups() {
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-4 items-start">
|
||||||
|
{/* Colonne gauche : identité + configuration NocoDB */}
|
||||||
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="name">Nom du groupe *</Label>
|
<Label htmlFor="name">Nom du groupe *</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -712,7 +721,214 @@ export default function Groups() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-3 pt-4">
|
</div>
|
||||||
|
|
||||||
|
{/* Colonne droite : coordonnées + envoi de mails */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Section Coordonnées du magasin (signature des mails) */}
|
||||||
|
<div className="border-t md:border-t-0 pt-4 md:pt-0 space-y-4">
|
||||||
|
<div className="flex items-center space-x-2 mb-1">
|
||||||
|
<div className="w-4 h-4 bg-green-500 rounded"></div>
|
||||||
|
<h3 className="font-medium text-gray-900">Coordonnées du magasin</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
Reprises dans la signature des mails envoyés aux fournisseurs.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="address">Adresse</Label>
|
||||||
|
<Textarea
|
||||||
|
id="address"
|
||||||
|
value={formData.address}
|
||||||
|
onChange={(e) => handleChange('address', e.target.value)}
|
||||||
|
placeholder={"12 rue des Fleurs\n54000 Nancy"}
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="phone">Téléphone</Label>
|
||||||
|
<Input
|
||||||
|
id="phone"
|
||||||
|
value={formData.phone}
|
||||||
|
onChange={(e) => handleChange('phone', e.target.value)}
|
||||||
|
placeholder="03 83 00 00 00"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="logo">Logo</Label>
|
||||||
|
<div className="flex items-center space-x-3 mt-1">
|
||||||
|
{formData.logo ? (
|
||||||
|
<img
|
||||||
|
src={formData.logo}
|
||||||
|
alt="Logo du magasin"
|
||||||
|
className="h-12 w-auto max-w-[120px] object-contain border rounded bg-white p-1"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="h-12 w-[120px] border border-dashed rounded flex items-center justify-center text-xs text-gray-400">
|
||||||
|
Aucun logo
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Input
|
||||||
|
id="logo"
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||||
|
onChange={handleLogoChange}
|
||||||
|
className="text-xs"
|
||||||
|
/>
|
||||||
|
{formData.logo && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-xs text-red-600 hover:text-red-700"
|
||||||
|
onClick={() => handleChange('logo', '')}
|
||||||
|
>
|
||||||
|
Retirer le logo
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
PNG ou JPEG, {MAX_LOGO_KB} Ko maximum. Affiché dans la signature des mails.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Section Configuration SMTP du magasin */}
|
||||||
|
<div className="border-t pt-4 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div className="w-4 h-4 bg-purple-500 rounded"></div>
|
||||||
|
<h3 className="font-medium text-gray-900">Envoi de mails (SMTP)</h3>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center space-x-2 text-sm text-gray-700 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.smtpEnabled}
|
||||||
|
onChange={(e) => handleChange('smtpEnabled', e.target.checked)}
|
||||||
|
className="rounded border-gray-300"
|
||||||
|
/>
|
||||||
|
<span>Activé</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
Serveur utilisé pour envoyer les demandes de facture aux fournisseurs
|
||||||
|
depuis le rapprochement. Chaque magasin utilise sa propre boîte mail.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{formData.smtpEnabled && (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div className="col-span-2">
|
||||||
|
<Label htmlFor="smtpHost">Serveur SMTP *</Label>
|
||||||
|
<Input
|
||||||
|
id="smtpHost"
|
||||||
|
value={formData.smtpHost}
|
||||||
|
onChange={(e) => handleChange('smtpHost', e.target.value)}
|
||||||
|
placeholder="smtp.office365.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="smtpPort">Port *</Label>
|
||||||
|
<Input
|
||||||
|
id="smtpPort"
|
||||||
|
type="number"
|
||||||
|
value={formData.smtpPort}
|
||||||
|
onChange={(e) => handleChange('smtpPort', e.target.value)}
|
||||||
|
placeholder="587"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center space-x-2 text-sm text-gray-700 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.smtpSecure}
|
||||||
|
onChange={(e) => handleChange('smtpSecure', e.target.checked)}
|
||||||
|
className="rounded border-gray-300"
|
||||||
|
/>
|
||||||
|
<span>Connexion SSL/TLS directe (port 465). Décoché = STARTTLS (port 587).</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="smtpUser">Identifiant</Label>
|
||||||
|
<Input
|
||||||
|
id="smtpUser"
|
||||||
|
value={formData.smtpUser}
|
||||||
|
onChange={(e) => handleChange('smtpUser', e.target.value)}
|
||||||
|
placeholder="magasin@lafoirfouille.fr"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="smtpPassword">Mot de passe</Label>
|
||||||
|
<Input
|
||||||
|
id="smtpPassword"
|
||||||
|
type="password"
|
||||||
|
value={formData.smtpPassword}
|
||||||
|
onChange={(e) => handleChange('smtpPassword', e.target.value)}
|
||||||
|
placeholder={
|
||||||
|
selectedGroup && (selectedGroup as any).smtpPasswordSet
|
||||||
|
? "Inchangé"
|
||||||
|
: "Mot de passe"
|
||||||
|
}
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="smtpSenderEmail">Adresse expéditeur *</Label>
|
||||||
|
<Input
|
||||||
|
id="smtpSenderEmail"
|
||||||
|
type="email"
|
||||||
|
value={formData.smtpSenderEmail}
|
||||||
|
onChange={(e) => handleChange('smtpSenderEmail', e.target.value)}
|
||||||
|
placeholder="magasin@lafoirfouille.fr"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="smtpSenderName">Nom affiché</Label>
|
||||||
|
<Input
|
||||||
|
id="smtpSenderName"
|
||||||
|
value={formData.smtpSenderName}
|
||||||
|
onChange={(e) => handleChange('smtpSenderName', e.target.value)}
|
||||||
|
placeholder={formData.name || "LaFoir'Fouille Nancy"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedGroup && (
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
disabled={testSmtpMutation.isPending}
|
||||||
|
onClick={() => testSmtpMutation.mutate(selectedGroup.id)}
|
||||||
|
>
|
||||||
|
{testSmtpMutation.isPending
|
||||||
|
? "Test en cours..."
|
||||||
|
: "✉️ Tester la connexion SMTP"}
|
||||||
|
</Button>
|
||||||
|
<p className="text-xs text-gray-500 mt-1 text-center">
|
||||||
|
Vérifie le serveur et les identifiants enregistrés, sans envoyer de message
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-3 pt-4 border-t">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -720,19 +936,7 @@ export default function Groups() {
|
|||||||
setShowCreateModal(false);
|
setShowCreateModal(false);
|
||||||
setShowEditModal(false);
|
setShowEditModal(false);
|
||||||
setSelectedGroup(null);
|
setSelectedGroup(null);
|
||||||
setFormData({
|
setFormData({ ...EMPTY_GROUP_FORM });
|
||||||
name: "",
|
|
||||||
color: "#1976D2",
|
|
||||||
nocodbConfigId: "",
|
|
||||||
nocodbTableName: "",
|
|
||||||
invoiceColumnName: "",
|
|
||||||
nocodbBlColumnName: "",
|
|
||||||
nocodbAmountColumnName: "",
|
|
||||||
nocodbInvoiceAmountTTCColumnName: "",
|
|
||||||
nocodbSupplierColumnName: "",
|
|
||||||
nocodbDueDateColumnName: "",
|
|
||||||
webhookUrl: "",
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Annuler
|
Annuler
|
||||||
|
|||||||
@@ -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) =>
|
||||||
@@ -183,7 +168,7 @@ export default function NocoDBConfig() {
|
|||||||
apiToken: config.apiToken,
|
apiToken: config.apiToken,
|
||||||
projectId: config.projectId,
|
projectId: config.projectId,
|
||||||
description: config.description || "",
|
description: config.description || "",
|
||||||
isActive: config.isActive,
|
isActive: config.isActive ?? undefined,
|
||||||
});
|
});
|
||||||
setShowEditModal(true);
|
setShowEditModal(true);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -29,7 +29,11 @@ import {
|
|||||||
CheckCircle,
|
CheckCircle,
|
||||||
Clock
|
Clock
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { 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();
|
||||||
@@ -52,24 +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'],
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: orders = [] } = useQuery({
|
// Comptages agrégés en base : la page n'affiche que des totaux, inutile de
|
||||||
queryKey: ['/api/orders'],
|
// rapatrier l'historique complet des commandes et des livraisons.
|
||||||
});
|
const { data: supplierStats = NO_STATS } = useQuery<EntityActivityStats[]>({
|
||||||
|
queryKey: ['/api/stats/by-supplier'],
|
||||||
const { data: deliveries = [] } = useQuery({
|
|
||||||
queryKey: ['/api/deliveries'],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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({
|
||||||
@@ -102,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']);
|
||||||
@@ -121,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);
|
||||||
}
|
}
|
||||||
@@ -155,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'] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -275,19 +270,25 @@ 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 supplierOrders = orders.filter((order: any) => order.supplierId === supplierId);
|
const stats = statsBySupplierId.get(supplierId);
|
||||||
const supplierDeliveries = deliveries.filter((delivery: any) => delivery.supplierId === supplierId);
|
|
||||||
const deliveredCount = supplierDeliveries.filter((delivery: any) => delivery.status === 'delivered').length;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
orders: supplierOrders.length,
|
orders: stats?.orders ?? 0,
|
||||||
deliveries: supplierDeliveries.length,
|
deliveries: stats?.deliveries ?? 0,
|
||||||
delivered: deliveredCount,
|
delivered: stats?.delivered ?? 0,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -1,568 +0,0 @@
|
|||||||
import { useState, useEffect } from "react";
|
|
||||||
|
|
||||||
// Interface pour les tâches
|
|
||||||
interface Task {
|
|
||||||
id: number;
|
|
||||||
title: string;
|
|
||||||
description?: string;
|
|
||||||
priority: 'low' | 'medium' | 'high';
|
|
||||||
status: 'pending' | 'completed';
|
|
||||||
assignedTo: string;
|
|
||||||
startDate?: string | null;
|
|
||||||
dueDate?: string | null;
|
|
||||||
createdAt?: string;
|
|
||||||
groupId?: number;
|
|
||||||
createdBy?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function TasksListSimple() {
|
|
||||||
const [tasks, setTasks] = useState<Task[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [editingId, setEditingId] = useState<number | null>(null);
|
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
|
||||||
const [statusFilter, setStatusFilter] = useState("all");
|
|
||||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
|
||||||
|
|
||||||
// Charger les tâches
|
|
||||||
const loadTasks = async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/tasks');
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
setTasks(data);
|
|
||||||
} else {
|
|
||||||
console.error('Erreur lors du chargement des tâches:', response.status);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Erreur lors du chargement des tâches:', error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadTasks();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Créer une tâche
|
|
||||||
const createTask = async (formData: FormData) => {
|
|
||||||
const title = formData.get('title') as string;
|
|
||||||
const description = formData.get('description') as string;
|
|
||||||
const priority = formData.get('priority') as string;
|
|
||||||
const status = formData.get('status') as string;
|
|
||||||
const assignedTo = formData.get('assignedTo') as string;
|
|
||||||
const startDate = formData.get('startDate') as string;
|
|
||||||
const dueDate = formData.get('dueDate') as string;
|
|
||||||
|
|
||||||
if (!title?.trim()) {
|
|
||||||
alert("Le titre est requis");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!assignedTo?.trim()) {
|
|
||||||
alert("L'assignation est requise");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const taskData = {
|
|
||||||
title: title.trim(),
|
|
||||||
description: description?.trim() || "",
|
|
||||||
priority: priority || "medium",
|
|
||||||
status: status || "pending",
|
|
||||||
assignedTo: assignedTo.trim(),
|
|
||||||
startDate: startDate || null,
|
|
||||||
dueDate: dueDate || null,
|
|
||||||
groupId: 1,
|
|
||||||
createdBy: 'admin',
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log('📤 Creating task:', taskData);
|
|
||||||
|
|
||||||
const response = await fetch('/api/tasks', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(taskData),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text();
|
|
||||||
console.error('❌ Create Error:', { status: response.status, error: errorText });
|
|
||||||
throw new Error(`Erreur ${response.status}: ${errorText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
alert("Tâche créée avec succès");
|
|
||||||
loadTasks();
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Erreur lors de la création:', error);
|
|
||||||
alert("Erreur lors de la création");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Modifier une tâche
|
|
||||||
const updateTask = async (id: number, formData: FormData) => {
|
|
||||||
const title = formData.get('title') as string;
|
|
||||||
const description = formData.get('description') as string;
|
|
||||||
const priority = formData.get('priority') as string;
|
|
||||||
const status = formData.get('status') as string;
|
|
||||||
const assignedTo = formData.get('assignedTo') as string;
|
|
||||||
const startDate = formData.get('startDate') as string;
|
|
||||||
const dueDate = formData.get('dueDate') as string;
|
|
||||||
|
|
||||||
if (!title?.trim()) {
|
|
||||||
alert("Le titre est requis");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!assignedTo?.trim()) {
|
|
||||||
alert("L'assignation est requise");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Préparer les données exactement comme le serveur les attend
|
|
||||||
const taskData: Partial<Task> = {
|
|
||||||
title: title.trim(),
|
|
||||||
description: description?.trim() || "",
|
|
||||||
priority: (priority as Task['priority']) || "medium",
|
|
||||||
status: (status as Task['status']) || "pending",
|
|
||||||
assignedTo: assignedTo.trim(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Gestion spéciale des dates pour éviter les erreurs de format
|
|
||||||
if (startDate && startDate.trim() !== '') {
|
|
||||||
taskData.startDate = startDate;
|
|
||||||
} else {
|
|
||||||
taskData.startDate = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dueDate && dueDate.trim() !== '') {
|
|
||||||
taskData.dueDate = dueDate;
|
|
||||||
} else {
|
|
||||||
taskData.dueDate = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('📤 Updating task:', { id, taskData });
|
|
||||||
|
|
||||||
const response = await fetch(`/api/tasks/${id}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(taskData),
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('📥 Update Response:', { status: response.status, ok: response.ok });
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text();
|
|
||||||
console.error('❌ Update Error:', { status: response.status, error: errorText });
|
|
||||||
throw new Error(`Erreur ${response.status}: ${errorText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
alert("Tâche modifiée avec succès");
|
|
||||||
setEditingId(null);
|
|
||||||
loadTasks();
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Erreur lors de la modification:', error);
|
|
||||||
alert("Erreur lors de la modification");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Filtrer les tâches
|
|
||||||
const filteredTasks = tasks.filter(task => {
|
|
||||||
const matchesSearch = !searchTerm || task.title?.toLowerCase().includes(searchTerm.toLowerCase());
|
|
||||||
const matchesStatus = statusFilter === "all" || task.status === statusFilter;
|
|
||||||
return matchesSearch && matchesStatus;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Formulaire inline
|
|
||||||
const TaskForm = ({ task, onSubmit, onCancel }: {
|
|
||||||
task?: Task;
|
|
||||||
onSubmit: (formData: FormData) => Promise<boolean>;
|
|
||||||
onCancel: () => void;
|
|
||||||
}) => {
|
|
||||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const formData = new FormData(e.currentTarget);
|
|
||||||
const success = await onSubmit(formData);
|
|
||||||
if (success && !task) {
|
|
||||||
(e.target as HTMLFormElement).reset();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<tr style={{ backgroundColor: '#f9fafb' }}>
|
|
||||||
<td colSpan={6} style={{ padding: '16px' }}>
|
|
||||||
<form onSubmit={handleSubmit} style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px', alignItems: 'end' }}>
|
|
||||||
<div>
|
|
||||||
<label style={{ display: 'block', fontSize: '12px', fontWeight: '500', marginBottom: '4px' }}>
|
|
||||||
Titre *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
name="title"
|
|
||||||
type="text"
|
|
||||||
defaultValue={task?.title || ""}
|
|
||||||
placeholder="Titre de la tâche"
|
|
||||||
required
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
padding: '6px 8px',
|
|
||||||
border: '1px solid #d1d5db',
|
|
||||||
borderRadius: '4px',
|
|
||||||
fontSize: '14px'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label style={{ display: 'block', fontSize: '12px', fontWeight: '500', marginBottom: '4px' }}>
|
|
||||||
Description
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
name="description"
|
|
||||||
type="text"
|
|
||||||
defaultValue={task?.description || ""}
|
|
||||||
placeholder="Description (optionnel)"
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
padding: '6px 8px',
|
|
||||||
border: '1px solid #d1d5db',
|
|
||||||
borderRadius: '4px',
|
|
||||||
fontSize: '14px'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label style={{ display: 'block', fontSize: '12px', fontWeight: '500', marginBottom: '4px' }}>
|
|
||||||
Priorité
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
name="priority"
|
|
||||||
defaultValue={task?.priority || "medium"}
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
padding: '6px 8px',
|
|
||||||
border: '1px solid #d1d5db',
|
|
||||||
borderRadius: '4px',
|
|
||||||
fontSize: '14px'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="low">🟢 Faible</option>
|
|
||||||
<option value="medium">🟡 Moyenne</option>
|
|
||||||
<option value="high">🔴 Élevée</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label style={{ display: 'block', fontSize: '12px', fontWeight: '500', marginBottom: '4px' }}>
|
|
||||||
Statut
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
name="status"
|
|
||||||
defaultValue={task?.status || "pending"}
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
padding: '6px 8px',
|
|
||||||
border: '1px solid #d1d5db',
|
|
||||||
borderRadius: '4px',
|
|
||||||
fontSize: '14px'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="pending">⏳ En cours</option>
|
|
||||||
<option value="completed">✅ Terminée</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label style={{ display: 'block', fontSize: '12px', fontWeight: '500', marginBottom: '4px' }}>
|
|
||||||
📅 Date de début
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
name="startDate"
|
|
||||||
type="date"
|
|
||||||
defaultValue={task?.startDate ? new Date(task.startDate).toISOString().split('T')[0] : ""}
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
padding: '6px 8px',
|
|
||||||
border: '1px solid #d1d5db',
|
|
||||||
borderRadius: '4px',
|
|
||||||
fontSize: '14px'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label style={{ display: 'block', fontSize: '12px', fontWeight: '500', marginBottom: '4px' }}>
|
|
||||||
⏰ Date d'échéance
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
name="dueDate"
|
|
||||||
type="date"
|
|
||||||
defaultValue={task?.dueDate ? new Date(task.dueDate).toISOString().split('T')[0] : ""}
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
padding: '6px 8px',
|
|
||||||
border: '1px solid #d1d5db',
|
|
||||||
borderRadius: '4px',
|
|
||||||
fontSize: '14px'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label style={{ display: 'block', fontSize: '12px', fontWeight: '500', marginBottom: '4px' }}>
|
|
||||||
Assigné à *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
name="assignedTo"
|
|
||||||
type="text"
|
|
||||||
defaultValue={task?.assignedTo || "admin"}
|
|
||||||
placeholder="Nom de la personne"
|
|
||||||
required
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
padding: '6px 8px',
|
|
||||||
border: '1px solid #d1d5db',
|
|
||||||
borderRadius: '4px',
|
|
||||||
fontSize: '14px'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: '8px' }}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onCancel}
|
|
||||||
style={{
|
|
||||||
padding: '6px 12px',
|
|
||||||
border: '1px solid #d1d5db',
|
|
||||||
borderRadius: '4px',
|
|
||||||
backgroundColor: 'white',
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontSize: '12px'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Annuler
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
style={{
|
|
||||||
padding: '6px 12px',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: '4px',
|
|
||||||
backgroundColor: '#3b82f6',
|
|
||||||
color: 'white',
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontSize: '12px'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{task ? "Modifier" : "Créer"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div style={{ padding: '24px', textAlign: 'center' }}>
|
|
||||||
<p>Chargement des tâches...</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ padding: '24px', maxWidth: '1400px', margin: '0 auto' }}>
|
|
||||||
{/* Header */}
|
|
||||||
<div style={{
|
|
||||||
backgroundColor: 'white',
|
|
||||||
border: '1px solid #e5e7eb',
|
|
||||||
borderRadius: '8px',
|
|
||||||
padding: '20px',
|
|
||||||
marginBottom: '20px',
|
|
||||||
boxShadow: '0 1px 3px rgba(0,0,0,0.1)'
|
|
||||||
}}>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
|
|
||||||
<h1 style={{ fontSize: '24px', fontWeight: '600', margin: '0', color: '#1f2937' }}>
|
|
||||||
📋 Gestion des Tâches
|
|
||||||
</h1>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowCreateForm(!showCreateForm)}
|
|
||||||
style={{
|
|
||||||
padding: '8px 16px',
|
|
||||||
backgroundColor: showCreateForm ? '#dc2626' : '#3b82f6',
|
|
||||||
color: 'white',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: '6px',
|
|
||||||
fontSize: '14px',
|
|
||||||
fontWeight: '500',
|
|
||||||
cursor: 'pointer'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{showCreateForm ? '❌ Annuler' : '➕ Nouvelle Tâche'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filtres */}
|
|
||||||
<div style={{ display: 'flex', gap: '16px', alignItems: 'center' }}>
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="🔍 Rechercher..."
|
|
||||||
value={searchTerm}
|
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
padding: '8px 12px',
|
|
||||||
border: '1px solid #d1d5db',
|
|
||||||
borderRadius: '6px',
|
|
||||||
fontSize: '14px'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<select
|
|
||||||
value={statusFilter}
|
|
||||||
onChange={(e) => setStatusFilter(e.target.value)}
|
|
||||||
style={{
|
|
||||||
padding: '8px 12px',
|
|
||||||
border: '1px solid #d1d5db',
|
|
||||||
borderRadius: '6px',
|
|
||||||
fontSize: '14px',
|
|
||||||
minWidth: '150px'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="all">Tous les statuts</option>
|
|
||||||
<option value="pending">En cours</option>
|
|
||||||
<option value="completed">Terminées</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tableau des tâches */}
|
|
||||||
<div style={{
|
|
||||||
backgroundColor: 'white',
|
|
||||||
border: '1px solid #e5e7eb',
|
|
||||||
borderRadius: '8px',
|
|
||||||
overflow: 'hidden',
|
|
||||||
boxShadow: '0 1px 3px rgba(0,0,0,0.1)'
|
|
||||||
}}>
|
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
|
||||||
<thead style={{ backgroundColor: '#f9fafb' }}>
|
|
||||||
<tr>
|
|
||||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontSize: '12px', fontWeight: '600', color: '#6b7280', borderBottom: '1px solid #e5e7eb' }}>Titre</th>
|
|
||||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontSize: '12px', fontWeight: '600', color: '#6b7280', borderBottom: '1px solid #e5e7eb' }}>Priorité</th>
|
|
||||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontSize: '12px', fontWeight: '600', color: '#6b7280', borderBottom: '1px solid #e5e7eb' }}>Statut</th>
|
|
||||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontSize: '12px', fontWeight: '600', color: '#6b7280', borderBottom: '1px solid #e5e7eb' }}>Assigné à</th>
|
|
||||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontSize: '12px', fontWeight: '600', color: '#6b7280', borderBottom: '1px solid #e5e7eb' }}>Dates</th>
|
|
||||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontSize: '12px', fontWeight: '600', color: '#6b7280', borderBottom: '1px solid #e5e7eb' }}>Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{showCreateForm && (
|
|
||||||
<TaskForm
|
|
||||||
onSubmit={createTask}
|
|
||||||
onCancel={() => setShowCreateForm(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{filteredTasks.length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={6} style={{ padding: '48px', textAlign: 'center', color: '#6b7280' }}>
|
|
||||||
Aucune tâche trouvée
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
|
||||||
filteredTasks.map((task) => (
|
|
||||||
editingId === task.id ? (
|
|
||||||
<TaskForm
|
|
||||||
key={task.id}
|
|
||||||
task={task}
|
|
||||||
onSubmit={(formData) => updateTask(task.id, formData)}
|
|
||||||
onCancel={() => setEditingId(null)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<tr key={task.id} style={{ borderBottom: '1px solid #e5e7eb' }}>
|
|
||||||
<td style={{ padding: '12px 16px' }}>
|
|
||||||
<div>
|
|
||||||
<div style={{
|
|
||||||
fontWeight: '500',
|
|
||||||
color: task.status === 'completed' ? '#6b7280' : '#1f2937',
|
|
||||||
textDecoration: task.status === 'completed' ? 'line-through' : 'none'
|
|
||||||
}}>
|
|
||||||
{task.title}
|
|
||||||
</div>
|
|
||||||
{task.description && (
|
|
||||||
<div style={{ fontSize: '12px', color: '#6b7280', marginTop: '2px' }}>
|
|
||||||
{task.description}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: '12px 16px' }}>
|
|
||||||
<span style={{
|
|
||||||
padding: '2px 8px',
|
|
||||||
borderRadius: '12px',
|
|
||||||
fontSize: '11px',
|
|
||||||
fontWeight: '500',
|
|
||||||
backgroundColor: task.priority === 'high' ? '#fee2e2' : task.priority === 'medium' ? '#fef3c7' : '#d1fae5',
|
|
||||||
color: task.priority === 'high' ? '#dc2626' : task.priority === 'medium' ? '#d97706' : '#059669'
|
|
||||||
}}>
|
|
||||||
{task.priority === 'high' ? '🔴 Élevée' : task.priority === 'medium' ? '🟡 Moyenne' : '🟢 Faible'}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: '12px 16px' }}>
|
|
||||||
<span style={{
|
|
||||||
padding: '2px 8px',
|
|
||||||
borderRadius: '12px',
|
|
||||||
fontSize: '11px',
|
|
||||||
fontWeight: '500',
|
|
||||||
backgroundColor: task.status === 'completed' ? '#d1fae5' : '#fef3c7',
|
|
||||||
color: task.status === 'completed' ? '#059669' : '#d97706'
|
|
||||||
}}>
|
|
||||||
{task.status === 'completed' ? '✅ Terminée' : '⏳ En cours'}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: '12px 16px', fontSize: '14px', color: '#6b7280' }}>
|
|
||||||
{task.assignedTo}
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: '12px 16px', fontSize: '12px', color: '#6b7280' }}>
|
|
||||||
{task.startDate && (
|
|
||||||
<div>📅 {new Date(task.startDate).toLocaleDateString('fr-FR')}</div>
|
|
||||||
)}
|
|
||||||
{task.dueDate && (
|
|
||||||
<div>⏰ {new Date(task.dueDate).toLocaleDateString('fr-FR')}</div>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: '12px 16px' }}>
|
|
||||||
<button
|
|
||||||
onClick={() => setEditingId(task.id)}
|
|
||||||
style={{
|
|
||||||
padding: '4px 8px',
|
|
||||||
border: '1px solid #d1d5db',
|
|
||||||
borderRadius: '4px',
|
|
||||||
backgroundColor: 'white',
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontSize: '12px'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
✏️ Modifier
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,546 +0,0 @@
|
|||||||
import { useState, useEffect } from "react";
|
|
||||||
|
|
||||||
// Version 100% autonome sans aucune dépendance externe
|
|
||||||
export default function TasksProductionSimple() {
|
|
||||||
const [tasks, setTasks] = useState<any[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
|
||||||
const [showEditModal, setShowEditModal] = useState(false);
|
|
||||||
const [selectedTask, setSelectedTask] = useState<any>(null);
|
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
|
||||||
const [statusFilter, setStatusFilter] = useState("all");
|
|
||||||
|
|
||||||
// Charger les tâches
|
|
||||||
const loadTasks = async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/tasks');
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
setTasks(data);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Erreur lors du chargement des tâches:', error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadTasks();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Filtrer les tâches
|
|
||||||
const filteredTasks = tasks.filter(task => {
|
|
||||||
const matchesSearch = task.title?.toLowerCase().includes(searchTerm.toLowerCase());
|
|
||||||
const matchesStatus = statusFilter === "all" || task.status === statusFilter;
|
|
||||||
return matchesSearch && matchesStatus;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Composant formulaire inline
|
|
||||||
const TaskForm = ({ task, onClose }: any) => {
|
|
||||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const formData = new FormData(e.currentTarget);
|
|
||||||
const title = formData.get('title') as string;
|
|
||||||
const description = formData.get('description') as string;
|
|
||||||
const priority = formData.get('priority') as string;
|
|
||||||
const status = formData.get('status') as string;
|
|
||||||
const assignedTo = formData.get('assignedTo') as string;
|
|
||||||
const startDate = formData.get('startDate') as string;
|
|
||||||
const dueDate = formData.get('dueDate') as string;
|
|
||||||
|
|
||||||
if (!title?.trim()) {
|
|
||||||
alert("Le titre est requis");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!assignedTo?.trim()) {
|
|
||||||
alert("L'assignation est requise");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const submitBtn = e.currentTarget.querySelector('button[type="submit"]') as HTMLButtonElement;
|
|
||||||
submitBtn.disabled = true;
|
|
||||||
submitBtn.textContent = "...";
|
|
||||||
|
|
||||||
try {
|
|
||||||
const taskData = {
|
|
||||||
title: title.trim(),
|
|
||||||
description: description?.trim() || "",
|
|
||||||
priority: priority || "medium",
|
|
||||||
status: status || "pending",
|
|
||||||
assignedTo: assignedTo.trim(),
|
|
||||||
startDate: startDate || null,
|
|
||||||
dueDate: dueDate || null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const url = task ? `/api/tasks/${task.id}` : '/api/tasks';
|
|
||||||
const method = task ? 'PUT' : 'POST';
|
|
||||||
|
|
||||||
if (!task) {
|
|
||||||
(taskData as any).groupId = 1;
|
|
||||||
(taskData as any).createdBy = 'admin';
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('📤 Sending request:', { url, method, taskData });
|
|
||||||
|
|
||||||
const response = await fetch(url, {
|
|
||||||
method,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(taskData),
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('📥 Response:', { status: response.status, ok: response.ok });
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text();
|
|
||||||
console.error('❌ API Error:', { status: response.status, error: errorText });
|
|
||||||
throw new Error(`Erreur ${response.status}: ${errorText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
alert(task ? "Tâche modifiée avec succès" : "Tâche créée avec succès");
|
|
||||||
onClose();
|
|
||||||
loadTasks(); // Recharger les tâches
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Erreur:', error);
|
|
||||||
alert("Erreur lors de l'opération");
|
|
||||||
submitBtn.disabled = false;
|
|
||||||
submitBtn.textContent = task ? "Modifier" : "Créer";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ padding: '24px', backgroundColor: 'white' }}>
|
|
||||||
<div style={{ borderBottom: '1px solid #e5e7eb', paddingBottom: '16px', marginBottom: '24px' }}>
|
|
||||||
<p style={{ fontSize: '14px', color: '#6b7280', margin: '0' }}>
|
|
||||||
📅 Date de début = Quand la tâche devient visible<br/>
|
|
||||||
⏰ Date d'échéance = Quand la tâche doit être terminée
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
style={{
|
|
||||||
float: 'right',
|
|
||||||
padding: '8px 16px',
|
|
||||||
border: '1px solid #d1d5db',
|
|
||||||
borderRadius: '6px',
|
|
||||||
backgroundColor: 'white',
|
|
||||||
cursor: 'pointer'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Annuler
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
|
||||||
<div>
|
|
||||||
<label style={{ display: 'block', fontSize: '14px', fontWeight: '500', marginBottom: '8px' }}>
|
|
||||||
Titre *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
name="title"
|
|
||||||
type="text"
|
|
||||||
defaultValue={task?.title || ""}
|
|
||||||
placeholder="Titre de la tâche"
|
|
||||||
required
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
padding: '8px 12px',
|
|
||||||
border: '1px solid #d1d5db',
|
|
||||||
borderRadius: '6px',
|
|
||||||
fontSize: '14px'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label style={{ display: 'block', fontSize: '14px', fontWeight: '500', marginBottom: '8px' }}>
|
|
||||||
Description
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
name="description"
|
|
||||||
defaultValue={task?.description || ""}
|
|
||||||
placeholder="Description de la tâche (optionnel)"
|
|
||||||
rows={3}
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
padding: '8px 12px',
|
|
||||||
border: '1px solid #d1d5db',
|
|
||||||
borderRadius: '6px',
|
|
||||||
fontSize: '14px',
|
|
||||||
resize: 'vertical'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
|
|
||||||
<div>
|
|
||||||
<label style={{ display: 'block', fontSize: '14px', fontWeight: '500', marginBottom: '8px' }}>
|
|
||||||
Priorité *
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
name="priority"
|
|
||||||
defaultValue={task?.priority || "medium"}
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
padding: '8px 12px',
|
|
||||||
border: '1px solid #d1d5db',
|
|
||||||
borderRadius: '6px',
|
|
||||||
fontSize: '14px'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="low">🟢 Faible</option>
|
|
||||||
<option value="medium">🟡 Moyenne</option>
|
|
||||||
<option value="high">🔴 Élevée</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label style={{ display: 'block', fontSize: '14px', fontWeight: '500', marginBottom: '8px' }}>
|
|
||||||
Statut
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
name="status"
|
|
||||||
defaultValue={task?.status || "pending"}
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
padding: '8px 12px',
|
|
||||||
border: '1px solid #d1d5db',
|
|
||||||
borderRadius: '6px',
|
|
||||||
fontSize: '14px'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="pending">⏳ En cours</option>
|
|
||||||
<option value="completed">✅ Terminée</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
|
|
||||||
<div>
|
|
||||||
<label style={{ display: 'block', fontSize: '14px', fontWeight: '500', marginBottom: '8px' }}>
|
|
||||||
📅 Date de début <span style={{ fontSize: '12px', color: '#6b7280' }}>(optionnel)</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
name="startDate"
|
|
||||||
type="date"
|
|
||||||
defaultValue={task?.startDate ? new Date(task.startDate).toISOString().split('T')[0] : ""}
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
padding: '8px 12px',
|
|
||||||
border: '1px solid #d1d5db',
|
|
||||||
borderRadius: '6px',
|
|
||||||
fontSize: '14px'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label style={{ display: 'block', fontSize: '14px', fontWeight: '500', marginBottom: '8px' }}>
|
|
||||||
⏰ Date d'échéance <span style={{ fontSize: '12px', color: '#6b7280' }}>(optionnel)</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
name="dueDate"
|
|
||||||
type="date"
|
|
||||||
defaultValue={task?.dueDate ? new Date(task.dueDate).toISOString().split('T')[0] : ""}
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
padding: '8px 12px',
|
|
||||||
border: '1px solid #d1d5db',
|
|
||||||
borderRadius: '6px',
|
|
||||||
fontSize: '14px'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label style={{ display: 'block', fontSize: '14px', fontWeight: '500', marginBottom: '8px' }}>
|
|
||||||
Assigné à *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
name="assignedTo"
|
|
||||||
type="text"
|
|
||||||
defaultValue={task?.assignedTo || "admin"}
|
|
||||||
placeholder="Nom de la personne assignée"
|
|
||||||
required
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
padding: '8px 12px',
|
|
||||||
border: '1px solid #d1d5db',
|
|
||||||
borderRadius: '6px',
|
|
||||||
fontSize: '14px'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end', marginTop: '24px' }}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
style={{
|
|
||||||
padding: '8px 16px',
|
|
||||||
border: '1px solid #d1d5db',
|
|
||||||
borderRadius: '6px',
|
|
||||||
backgroundColor: 'white',
|
|
||||||
cursor: 'pointer'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Annuler
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
style={{
|
|
||||||
padding: '8px 16px',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: '6px',
|
|
||||||
backgroundColor: '#3b82f6',
|
|
||||||
color: 'white',
|
|
||||||
cursor: 'pointer'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{task ? "Modifier" : "Créer"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div style={{ padding: '24px', textAlign: 'center' }}>
|
|
||||||
<p>Chargement des tâches...</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ padding: '24px', maxWidth: '1200px', margin: '0 auto' }}>
|
|
||||||
{/* Header */}
|
|
||||||
<div style={{
|
|
||||||
backgroundColor: 'white',
|
|
||||||
border: '1px solid #e5e7eb',
|
|
||||||
borderRadius: '8px',
|
|
||||||
padding: '24px',
|
|
||||||
marginBottom: '24px',
|
|
||||||
boxShadow: '0 1px 3px rgba(0,0,0,0.1)'
|
|
||||||
}}>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
|
|
||||||
<h1 style={{ fontSize: '24px', fontWeight: '600', margin: '0', color: '#1f2937' }}>
|
|
||||||
📋 Gestion des Tâches
|
|
||||||
</h1>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowCreateModal(true)}
|
|
||||||
style={{
|
|
||||||
padding: '10px 20px',
|
|
||||||
backgroundColor: '#3b82f6',
|
|
||||||
color: 'white',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: '6px',
|
|
||||||
fontSize: '14px',
|
|
||||||
fontWeight: '500',
|
|
||||||
cursor: 'pointer',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '8px'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
➕ Nouvelle Tâche
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filtres */}
|
|
||||||
<div style={{ display: 'flex', gap: '16px', alignItems: 'center' }}>
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="🔍 Rechercher..."
|
|
||||||
value={searchTerm}
|
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
padding: '8px 12px',
|
|
||||||
border: '1px solid #d1d5db',
|
|
||||||
borderRadius: '6px',
|
|
||||||
fontSize: '14px'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<select
|
|
||||||
value={statusFilter}
|
|
||||||
onChange={(e) => setStatusFilter(e.target.value)}
|
|
||||||
style={{
|
|
||||||
padding: '8px 12px',
|
|
||||||
border: '1px solid #d1d5db',
|
|
||||||
borderRadius: '6px',
|
|
||||||
fontSize: '14px',
|
|
||||||
minWidth: '150px'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="all">Tous les statuts</option>
|
|
||||||
<option value="pending">En cours</option>
|
|
||||||
<option value="completed">Terminées</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Liste des tâches */}
|
|
||||||
<div style={{
|
|
||||||
backgroundColor: 'white',
|
|
||||||
border: '1px solid #e5e7eb',
|
|
||||||
borderRadius: '8px',
|
|
||||||
overflow: 'hidden',
|
|
||||||
boxShadow: '0 1px 3px rgba(0,0,0,0.1)'
|
|
||||||
}}>
|
|
||||||
{filteredTasks.length === 0 ? (
|
|
||||||
<div style={{ padding: '48px', textAlign: 'center', color: '#6b7280' }}>
|
|
||||||
<p>Aucune tâche trouvée</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
filteredTasks.map((task, index) => (
|
|
||||||
<div
|
|
||||||
key={task.id}
|
|
||||||
style={{
|
|
||||||
padding: '16px 24px',
|
|
||||||
borderBottom: index < filteredTasks.length - 1 ? '1px solid #e5e7eb' : 'none',
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<h3 style={{
|
|
||||||
margin: '0 0 8px 0',
|
|
||||||
fontSize: '16px',
|
|
||||||
fontWeight: '500',
|
|
||||||
color: task.status === 'completed' ? '#6b7280' : '#1f2937',
|
|
||||||
textDecoration: task.status === 'completed' ? 'line-through' : 'none'
|
|
||||||
}}>
|
|
||||||
{task.title}
|
|
||||||
</h3>
|
|
||||||
<div style={{ display: 'flex', gap: '16px', fontSize: '14px', color: '#6b7280' }}>
|
|
||||||
<span>
|
|
||||||
Priorité: {task.priority === 'high' ? '🔴 Élevée' : task.priority === 'medium' ? '🟡 Moyenne' : '🟢 Faible'}
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
Statut: {task.status === 'completed' ? '✅ Terminée' : '⏳ En cours'}
|
|
||||||
</span>
|
|
||||||
<span>Assigné à: {task.assignedTo}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', gap: '8px' }}>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedTask(task);
|
|
||||||
setShowEditModal(true);
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
padding: '6px 12px',
|
|
||||||
border: '1px solid #d1d5db',
|
|
||||||
borderRadius: '4px',
|
|
||||||
backgroundColor: 'white',
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontSize: '12px'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
✏️ Modifier
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Modal de création */}
|
|
||||||
{showCreateModal && (
|
|
||||||
<div style={{
|
|
||||||
position: 'fixed',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
|
||||||
zIndex: 1000,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center'
|
|
||||||
}}>
|
|
||||||
<div style={{
|
|
||||||
backgroundColor: 'white',
|
|
||||||
borderRadius: '8px',
|
|
||||||
maxWidth: '600px',
|
|
||||||
width: '90%',
|
|
||||||
maxHeight: '90vh',
|
|
||||||
overflow: 'auto',
|
|
||||||
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)'
|
|
||||||
}}>
|
|
||||||
<div style={{
|
|
||||||
padding: '24px 24px 16px',
|
|
||||||
borderBottom: '1px solid #e5e7eb'
|
|
||||||
}}>
|
|
||||||
<h2 style={{
|
|
||||||
fontSize: '18px',
|
|
||||||
fontWeight: '600',
|
|
||||||
margin: '0'
|
|
||||||
}}>
|
|
||||||
Créer une nouvelle tâche
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<TaskForm onClose={() => setShowCreateModal(false)} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Modal d'édition */}
|
|
||||||
{showEditModal && selectedTask && (
|
|
||||||
<div style={{
|
|
||||||
position: 'fixed',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
|
||||||
zIndex: 1000,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center'
|
|
||||||
}}>
|
|
||||||
<div style={{
|
|
||||||
backgroundColor: 'white',
|
|
||||||
borderRadius: '8px',
|
|
||||||
maxWidth: '600px',
|
|
||||||
width: '90%',
|
|
||||||
maxHeight: '90vh',
|
|
||||||
overflow: 'auto',
|
|
||||||
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)'
|
|
||||||
}}>
|
|
||||||
<div style={{
|
|
||||||
padding: '24px 24px 16px',
|
|
||||||
borderBottom: '1px solid #e5e7eb'
|
|
||||||
}}>
|
|
||||||
<h2 style={{
|
|
||||||
fontSize: '18px',
|
|
||||||
fontWeight: '600',
|
|
||||||
margin: '0'
|
|
||||||
}}>
|
|
||||||
Modifier la tâche
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<TaskForm
|
|
||||||
task={selectedTask}
|
|
||||||
onClose={() => {
|
|
||||||
setShowEditModal(false);
|
|
||||||
setSelectedTask(null);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,504 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
|
||||||
import { useStore } from "@/contexts/StoreContext";
|
|
||||||
import { useToast } from "@/hooks/use-toast";
|
|
||||||
import { apiRequest } from "@/lib/queryClient";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
|
||||||
import {
|
|
||||||
ListTodo,
|
|
||||||
Plus,
|
|
||||||
Search,
|
|
||||||
Circle,
|
|
||||||
Clock,
|
|
||||||
AlertTriangle,
|
|
||||||
CheckCircle,
|
|
||||||
Edit,
|
|
||||||
Trash2
|
|
||||||
} from "lucide-react";
|
|
||||||
import { format } from "date-fns";
|
|
||||||
import { fr } from "date-fns/locale";
|
|
||||||
import { Task } from "@shared/schema";
|
|
||||||
|
|
||||||
type TaskWithRelations = Task & {
|
|
||||||
creator: { id: string; username: string; name: string; };
|
|
||||||
group: { id: number; name: string; color: string; };
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function TasksSimplified() {
|
|
||||||
const { user } = useAuthUnified();
|
|
||||||
const { selectedStoreId } = useStore();
|
|
||||||
const { toast } = useToast();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
// États locaux
|
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
|
||||||
const [statusFilter, setStatusFilter] = useState<string>("all");
|
|
||||||
const [priorityFilter, setPriorityFilter] = useState<string>("all");
|
|
||||||
|
|
||||||
// Modals et tâche sélectionnée
|
|
||||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
|
||||||
const [showEditModal, setShowEditModal] = useState(false);
|
|
||||||
const [selectedTask, setSelectedTask] = useState<TaskWithRelations | null>(null);
|
|
||||||
|
|
||||||
// Récupération des tâches
|
|
||||||
const { data: tasks = [], isLoading } = useQuery({
|
|
||||||
queryKey: ["/api/tasks", selectedStoreId],
|
|
||||||
queryFn: () => {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (selectedStoreId) {
|
|
||||||
params.append('storeId', selectedStoreId.toString());
|
|
||||||
}
|
|
||||||
return fetch(`/api/tasks?${params.toString()}`).then(res => res.json());
|
|
||||||
},
|
|
||||||
enabled: !!user,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Récupération des magasins
|
|
||||||
const { data: stores = [] } = useQuery<any[]>({
|
|
||||||
queryKey: ["/api/groups"],
|
|
||||||
enabled: !!user,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Filtrer les tâches
|
|
||||||
const filteredTasks = tasks.filter((task: TaskWithRelations) => {
|
|
||||||
const matchesSearch = task.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
||||||
task.description?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
||||||
task.assignedTo?.toLowerCase().includes(searchTerm.toLowerCase());
|
|
||||||
|
|
||||||
const matchesStatus = statusFilter === "all" || task.status === statusFilter;
|
|
||||||
const matchesPriority = priorityFilter === "all" || task.priority === priorityFilter;
|
|
||||||
|
|
||||||
return matchesSearch && matchesStatus && matchesPriority;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Création d'une tâche
|
|
||||||
const handleCreateTask = async (taskData: any) => {
|
|
||||||
try {
|
|
||||||
const finalData = {
|
|
||||||
...taskData,
|
|
||||||
groupId: selectedStoreId ? parseInt(selectedStoreId) : stores[0]?.id
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log('🔍 TasksSimplified - Creating task with data:', {
|
|
||||||
selectedStoreId,
|
|
||||||
storesLength: stores.length,
|
|
||||||
finalGroupId: finalData.groupId,
|
|
||||||
taskData: finalData
|
|
||||||
});
|
|
||||||
|
|
||||||
await apiRequest('/api/tasks', 'POST', finalData);
|
|
||||||
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["/api/tasks"] });
|
|
||||||
setShowCreateModal(false);
|
|
||||||
toast({
|
|
||||||
title: "Succès",
|
|
||||||
description: "Tâche créée avec succès",
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ TasksSimplified - Error creating task:', error);
|
|
||||||
toast({
|
|
||||||
title: "Erreur",
|
|
||||||
description: "Impossible de créer la tâche",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Modification d'une tâche
|
|
||||||
const handleUpdateTask = async (taskData: any) => {
|
|
||||||
if (!selectedTask) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await apiRequest(`/api/tasks/${selectedTask.id}`, 'PUT', taskData);
|
|
||||||
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["/api/tasks"] });
|
|
||||||
setShowEditModal(false);
|
|
||||||
setSelectedTask(null);
|
|
||||||
toast({
|
|
||||||
title: "Succès",
|
|
||||||
description: "Tâche modifiée avec succès",
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
toast({
|
|
||||||
title: "Erreur",
|
|
||||||
description: "Impossible de modifier la tâche",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Complétion d'une tâche
|
|
||||||
const handleCompleteTask = async (taskId: number) => {
|
|
||||||
try {
|
|
||||||
await apiRequest(`/api/tasks/${taskId}/complete`, 'POST');
|
|
||||||
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["/api/tasks"] });
|
|
||||||
toast({
|
|
||||||
title: "Succès",
|
|
||||||
description: "Tâche marquée comme terminée",
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
toast({
|
|
||||||
title: "Erreur",
|
|
||||||
description: "Impossible de compléter la tâche",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Suppression d'une tâche
|
|
||||||
const handleDeleteTask = async (taskId: number) => {
|
|
||||||
try {
|
|
||||||
await apiRequest(`/api/tasks/${taskId}`, 'DELETE');
|
|
||||||
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["/api/tasks"] });
|
|
||||||
toast({
|
|
||||||
title: "Succès",
|
|
||||||
description: "Tâche supprimée avec succès",
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
toast({
|
|
||||||
title: "Erreur",
|
|
||||||
description: "Impossible de supprimer la tâche",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEditTask = (task: TaskWithRelations) => {
|
|
||||||
setSelectedTask(task);
|
|
||||||
setShowEditModal(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusColor = (status: string) => {
|
|
||||||
switch (status) {
|
|
||||||
case "completed": return "bg-green-100 text-green-800";
|
|
||||||
case "pending": return "bg-yellow-100 text-yellow-800";
|
|
||||||
default: return "bg-gray-100 text-gray-800";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getPriorityColor = (priority: string) => {
|
|
||||||
switch (priority) {
|
|
||||||
case "high": return "bg-red-100 text-red-800";
|
|
||||||
case "medium": return "bg-blue-100 text-blue-800";
|
|
||||||
case "low": return "bg-green-100 text-green-800";
|
|
||||||
default: return "bg-gray-100 text-gray-800";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center h-64">
|
|
||||||
<div className="text-lg">Chargement des tâches...</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center h-64">
|
|
||||||
<div className="text-lg">Vous devez être connecté pour accéder aux tâches.</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<ListTodo className="h-6 w-6 text-blue-600" />
|
|
||||||
<h1 className="text-2xl font-bold">Gestion des Tâches</h1>
|
|
||||||
</div>
|
|
||||||
<Dialog open={showCreateModal} onOpenChange={setShowCreateModal}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button>
|
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
|
||||||
Nouvelle Tâche
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Créer une nouvelle tâche</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<TaskForm onSubmit={handleCreateTask} />
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filtres */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-lg">Filtres</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="flex flex-wrap gap-4">
|
|
||||||
<div className="flex-1 min-w-[200px]">
|
|
||||||
<div className="relative">
|
|
||||||
<Search className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
|
|
||||||
<Input
|
|
||||||
placeholder="Rechercher une tâche..."
|
|
||||||
value={searchTerm}
|
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
|
||||||
className="pl-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="min-w-[150px]">
|
|
||||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Statut" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">Tous les statuts</SelectItem>
|
|
||||||
<SelectItem value="pending">En attente</SelectItem>
|
|
||||||
<SelectItem value="completed">Terminée</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="min-w-[150px]">
|
|
||||||
<Select value={priorityFilter} onValueChange={setPriorityFilter}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Priorité" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">Toutes priorités</SelectItem>
|
|
||||||
<SelectItem value="high">Haute</SelectItem>
|
|
||||||
<SelectItem value="medium">Moyenne</SelectItem>
|
|
||||||
<SelectItem value="low">Basse</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Liste des tâches */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
{filteredTasks.length === 0 ? (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex items-center justify-center h-32">
|
|
||||||
<div className="text-center">
|
|
||||||
<ListTodo className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
|
||||||
<p className="text-gray-500">Aucune tâche trouvée</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : (
|
|
||||||
filteredTasks.map((task: TaskWithRelations) => (
|
|
||||||
<Card key={task.id} className={`hover:shadow-md transition-shadow ${task.status === 'completed' ? 'opacity-60 bg-gray-50' : ''}`}>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center space-x-3 mb-2">
|
|
||||||
<h3 className={`font-semibold text-lg ${task.status === 'completed' ? 'line-through text-gray-500' : ''}`}>{task.title}</h3>
|
|
||||||
<Badge className={getStatusColor(task.status)}>
|
|
||||||
{task.status === "completed" ? "Terminée" : "En attente"}
|
|
||||||
</Badge>
|
|
||||||
<Badge className={getPriorityColor(task.priority)}>
|
|
||||||
{task.priority === "high" ? "Haute" : task.priority === "medium" ? "Moyenne" : "Basse"}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{task.description && (
|
|
||||||
<p className={`mb-3 ${task.status === 'completed' ? 'text-gray-400 line-through' : 'text-gray-600'}`}>{task.description}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-4 text-sm text-gray-500">
|
|
||||||
<div className="flex items-center space-x-1">
|
|
||||||
<span className="font-medium">Assigné à:</span>
|
|
||||||
<span>{task.assignedTo || "Non assigné"}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center space-x-1">
|
|
||||||
<span className="font-medium">Magasin:</span>
|
|
||||||
<span className="px-2 py-1 rounded text-xs" style={{ backgroundColor: task.group.color + '20', color: task.group.color }}>
|
|
||||||
{task.group.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center space-x-1">
|
|
||||||
<span className="font-medium">Créé par:</span>
|
|
||||||
<span>{task.creator.name || task.creator.username}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{task.dueDate && (
|
|
||||||
<div className="flex items-center space-x-1">
|
|
||||||
<Clock className="w-4 h-4" />
|
|
||||||
<span>
|
|
||||||
Échéance: {format(new Date(task.dueDate), 'dd/MM/yyyy HH:mm', { locale: fr })}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2 ml-4">
|
|
||||||
{task.status !== "completed" && (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => handleCompleteTask(task.id)}
|
|
||||||
className="text-green-600 hover:text-green-700"
|
|
||||||
>
|
|
||||||
<CheckCircle className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => handleEditTask(task)}
|
|
||||||
>
|
|
||||||
<Edit className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => handleDeleteTask(task.id)}
|
|
||||||
className="text-red-600 hover:text-red-700"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Modal de modification */}
|
|
||||||
<Dialog open={showEditModal} onOpenChange={setShowEditModal}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Modifier la tâche</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
{selectedTask && (
|
|
||||||
<TaskForm
|
|
||||||
initialData={selectedTask}
|
|
||||||
onSubmit={handleUpdateTask}
|
|
||||||
isEditing={true}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Composant de formulaire simplifié
|
|
||||||
function TaskForm({ onSubmit, initialData, isEditing = false }: {
|
|
||||||
onSubmit: (data: any) => void;
|
|
||||||
initialData?: TaskWithRelations;
|
|
||||||
isEditing?: boolean;
|
|
||||||
}) {
|
|
||||||
const [title, setTitle] = useState(initialData?.title || "");
|
|
||||||
const [description, setDescription] = useState(initialData?.description || "");
|
|
||||||
const [assignedTo, setAssignedTo] = useState(initialData?.assignedTo || "");
|
|
||||||
const [priority, setPriority] = useState(initialData?.priority || "medium");
|
|
||||||
const [dueDate, setDueDate] = useState(
|
|
||||||
initialData?.dueDate ? format(new Date(initialData.dueDate), 'yyyy-MM-dd') : ""
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
if (!title.trim()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const taskData = {
|
|
||||||
title: title.trim(),
|
|
||||||
description: description.trim() || undefined,
|
|
||||||
assignedTo: assignedTo?.trim() || "Non assigné",
|
|
||||||
priority,
|
|
||||||
startDate: null, // Toujours inclure startDate même si null
|
|
||||||
dueDate: dueDate ? new Date(dueDate).toISOString() : null,
|
|
||||||
};
|
|
||||||
|
|
||||||
onSubmit(taskData);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label htmlFor="title" className="block text-sm font-medium mb-1">
|
|
||||||
Titre de la tâche *
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
id="title"
|
|
||||||
value={title}
|
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
|
||||||
placeholder="Titre de la tâche"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="description" className="block text-sm font-medium mb-1">
|
|
||||||
Description
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
id="description"
|
|
||||||
value={description}
|
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
|
||||||
placeholder="Description de la tâche"
|
|
||||||
className="w-full p-2 border rounded-md"
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="assignedTo" className="block text-sm font-medium mb-1">
|
|
||||||
Assigné à <span className="text-xs text-gray-500">(optionnel)</span>
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
id="assignedTo"
|
|
||||||
value={assignedTo}
|
|
||||||
onChange={(e) => setAssignedTo(e.target.value)}
|
|
||||||
placeholder="Nom de la personne responsable"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="priority" className="block text-sm font-medium mb-1">
|
|
||||||
Priorité
|
|
||||||
</label>
|
|
||||||
<Select value={priority} onValueChange={setPriority}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="low">Basse</SelectItem>
|
|
||||||
<SelectItem value="medium">Moyenne</SelectItem>
|
|
||||||
<SelectItem value="high">Haute</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="dueDate" className="block text-sm font-medium mb-1">
|
|
||||||
Date d'échéance
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
id="dueDate"
|
|
||||||
type="date"
|
|
||||||
value={dueDate}
|
|
||||||
onChange={(e) => setDueDate(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end space-x-2">
|
|
||||||
<Button type="submit">
|
|
||||||
{isEditing ? "Modifier" : "Créer"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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);
|
||||||
@@ -737,7 +740,7 @@ export default function UsersPage() {
|
|||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div
|
<div
|
||||||
className="w-2 h-2 rounded-full mr-1"
|
className="w-2 h-2 rounded-full mr-1"
|
||||||
style={{ backgroundColor: userGroup.group.color }}
|
style={{ backgroundColor: userGroup.group.color || undefined }}
|
||||||
/>
|
/>
|
||||||
{userGroup.group.name}
|
{userGroup.group.name}
|
||||||
</div>
|
</div>
|
||||||
@@ -901,7 +904,7 @@ export default function UsersPage() {
|
|||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<div
|
<div
|
||||||
className="w-3 h-3 rounded-full"
|
className="w-3 h-3 rounded-full"
|
||||||
style={{ backgroundColor: group.color }}
|
style={{ backgroundColor: group.color || undefined }}
|
||||||
/>
|
/>
|
||||||
<span className="text-sm">{group.name}</span>
|
<span className="text-sm">{group.name}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1063,7 +1066,7 @@ export default function UsersPage() {
|
|||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<div
|
<div
|
||||||
className="w-3 h-3 rounded-full"
|
className="w-3 h-3 rounded-full"
|
||||||
style={{ backgroundColor: group.color }}
|
style={{ backgroundColor: group.color || undefined }}
|
||||||
/>
|
/>
|
||||||
<Label htmlFor={`group-${group.id}`} className="text-sm">
|
<Label htmlFor={`group-${group.id}`} className="text-sm">
|
||||||
{group.name}
|
{group.name}
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -23,8 +23,7 @@ import {
|
|||||||
X,
|
X,
|
||||||
Filter,
|
Filter,
|
||||||
Loader2,
|
Loader2,
|
||||||
MessageSquare,
|
MessageSquare
|
||||||
Search
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { fr } from "date-fns/locale";
|
import { fr } from "date-fns/locale";
|
||||||
@@ -78,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();
|
||||||
@@ -89,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 [];
|
||||||
@@ -268,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) {
|
||||||
@@ -553,7 +560,7 @@ export default function MobileCustomerOrdersPage() {
|
|||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="isPromotionalPrice"
|
name={"isPromotionalPrice" as any}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
|
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
|
||||||
<FormControl>
|
<FormControl>
|
||||||
@@ -573,7 +580,7 @@ export default function MobileCustomerOrdersPage() {
|
|||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="customerNotified"
|
name={"customerNotified" as any}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
|
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
|
||||||
<FormControl>
|
<FormControl>
|
||||||
|
|||||||
@@ -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,5 +0,0 @@
|
|||||||
# Netscape HTTP Cookie File
|
|
||||||
# https://curl.se/docs/http-cookies.html
|
|
||||||
# This file was generated by libcurl! Edit at your own risk.
|
|
||||||
|
|
||||||
#HttpOnly_localhost FALSE / FALSE 1758639574 connect.sid s%3A8_9iOHdXBiypBHfh9CFiGGT7-mgFDQ-9.MPmF2NrBG7ywTs2h9HX4yW1JVNQ5dbq%2FHoZpz8jRTpo
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
# Netscape HTTP Cookie File
|
|
||||||
# https://curl.se/docs/http-cookies.html
|
|
||||||
# This file was generated by libcurl! Edit at your own risk.
|
|
||||||
|
|
||||||
#HttpOnly_localhost FALSE / FALSE 1755861696 connect.sid s%3AI3ww2VSYnJCTbvhTLFfsKWucSCgLEXnH.Q%2FZB%2FqjpNIGDgcSjAp4O%2BHP03Qrmj71qIb8njdhnrK4
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
-- Script DEFINITIF pour créer la table webhook_bap_config en production
|
|
||||||
-- À exécuter directement sur votre PostgreSQL de production
|
|
||||||
-- ATTENTION : Remplacez YOUR_DATABASE par le nom de votre base
|
|
||||||
|
|
||||||
\c YOUR_DATABASE;
|
|
||||||
|
|
||||||
-- Créer la table webhook_bap_config
|
|
||||||
DROP TABLE IF EXISTS webhook_bap_config CASCADE;
|
|
||||||
|
|
||||||
CREATE TABLE webhook_bap_config (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
name VARCHAR(100) NOT NULL DEFAULT 'Configuration BAP',
|
|
||||||
webhook_url TEXT NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Insérer la configuration par défaut avec VOTRE URL
|
|
||||||
INSERT INTO webhook_bap_config (name, webhook_url, description, is_active) VALUES (
|
|
||||||
'Configuration BAP',
|
|
||||||
'https://workflow.ffnancy.fr/webhook-test/a3d03176-b72f-412d-8fb9-f920b9fbab4d',
|
|
||||||
'Configuration pour envoi des fichiers BAP vers n8n',
|
|
||||||
true
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Créer la table migrations si elle n'existe pas
|
|
||||||
CREATE TABLE IF NOT EXISTS migrations (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
filename VARCHAR(255) UNIQUE NOT NULL,
|
|
||||||
executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Marquer la migration comme appliquée
|
|
||||||
INSERT INTO migrations (filename) VALUES ('20250903141000_create_webhook_bap_config.sql')
|
|
||||||
ON CONFLICT (filename) DO NOTHING;
|
|
||||||
|
|
||||||
-- Vérifier que tout est créé
|
|
||||||
SELECT 'Table webhook_bap_config créée avec succès' as message;
|
|
||||||
SELECT * FROM webhook_bap_config;
|
|
||||||
SELECT 'Table migrations mise à jour' as message;
|
|
||||||
SELECT * FROM migrations WHERE filename LIKE '%webhook_bap%';
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
-- Script SQL simple pour créer UNIQUEMENT la table announcements en production
|
|
||||||
-- À exécuter directement dans le conteneur Docker
|
|
||||||
|
|
||||||
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',
|
|
||||||
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_priority_check CHECK (priority IN ('normal', 'important', 'urgent')),
|
|
||||||
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éer les index pour les performances
|
|
||||||
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);
|
|
||||||
|
|
||||||
-- Vérification
|
|
||||||
SELECT 'Table announcements créée avec succès' as resultat;
|
|
||||||
SELECT column_name, data_type FROM information_schema.columns WHERE table_name = 'announcements' ORDER BY ordinal_position;
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
|
|
||||||
// Script de débogage pour tester les endpoints de production
|
|
||||||
import https from 'https';
|
|
||||||
|
|
||||||
const BASE_URL = 'https://logiflow.ffnancy.fr';
|
|
||||||
|
|
||||||
async function makeRequest(path, options = {}) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const url = new URL(path, BASE_URL);
|
|
||||||
const requestOptions = {
|
|
||||||
hostname: url.hostname,
|
|
||||||
path: url.pathname + url.search,
|
|
||||||
method: options.method || 'GET',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'User-Agent': 'Debug-Script/1.0',
|
|
||||||
...options.headers
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const req = https.request(requestOptions, (res) => {
|
|
||||||
let data = '';
|
|
||||||
res.on('data', (chunk) => data += chunk);
|
|
||||||
res.on('end', () => {
|
|
||||||
resolve({
|
|
||||||
status: res.statusCode,
|
|
||||||
headers: res.headers,
|
|
||||||
data: data,
|
|
||||||
length: data.length
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
req.on('error', (error) => {
|
|
||||||
reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (options.body) {
|
|
||||||
req.write(options.body);
|
|
||||||
}
|
|
||||||
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function debugProduction() {
|
|
||||||
console.log('🔍 Testing production endpoints...\n');
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Test 1: Health check
|
|
||||||
console.log('1. Health check:');
|
|
||||||
const health = await makeRequest('/api/health');
|
|
||||||
console.log(` Status: ${health.status}`);
|
|
||||||
console.log(` Response: ${health.data.substring(0, 100)}...\n`);
|
|
||||||
|
|
||||||
// Test 2: Default credentials check
|
|
||||||
console.log('2. Default credentials check:');
|
|
||||||
const defaultCreds = await makeRequest('/api/default-credentials-check');
|
|
||||||
console.log(` Status: ${defaultCreds.status}`);
|
|
||||||
console.log(` Response: ${defaultCreds.data}\n`);
|
|
||||||
|
|
||||||
// Test 3: Login attempts with different credentials
|
|
||||||
const credentialsList = [
|
|
||||||
{ username: 'admin', password: 'admin' },
|
|
||||||
{ username: 'administrator', password: 'administrator' },
|
|
||||||
{ username: 'lff', password: 'lff2024' },
|
|
||||||
{ username: 'logiflow', password: 'logiflow' },
|
|
||||||
{ username: 'root', password: 'root' }
|
|
||||||
];
|
|
||||||
|
|
||||||
let login = null;
|
|
||||||
let cookies = '';
|
|
||||||
|
|
||||||
for (const creds of credentialsList) {
|
|
||||||
console.log(`3. Login attempt with ${creds.username}:${creds.password}:`);
|
|
||||||
login = await makeRequest('/api/login', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify(creds)
|
|
||||||
});
|
|
||||||
console.log(` Status: ${login.status}`);
|
|
||||||
console.log(` Response: ${login.data.substring(0, 200)}...\n`);
|
|
||||||
|
|
||||||
if (login.status === 200 && login.headers['set-cookie']) {
|
|
||||||
cookies = login.headers['set-cookie'].map(c => c.split(';')[0]).join('; ');
|
|
||||||
console.log(`✅ Successful login with ${creds.username}\n`);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cookies already extracted above if login was successful
|
|
||||||
|
|
||||||
// Test 4: User info (with cookies)
|
|
||||||
if (cookies) {
|
|
||||||
console.log('4. User info (authenticated):');
|
|
||||||
const user = await makeRequest('/api/user', {
|
|
||||||
headers: { 'Cookie': cookies }
|
|
||||||
});
|
|
||||||
console.log(` Status: ${user.status}`);
|
|
||||||
console.log(` Response: ${user.data.substring(0, 200)}...\n`);
|
|
||||||
|
|
||||||
// Test 5: Tasks (with cookies)
|
|
||||||
console.log('5. Tasks list (authenticated):');
|
|
||||||
const tasks = await makeRequest('/api/tasks', {
|
|
||||||
headers: { 'Cookie': cookies }
|
|
||||||
});
|
|
||||||
console.log(` Status: ${tasks.status}`);
|
|
||||||
console.log(` Response: ${tasks.data.substring(0, 300)}...\n`);
|
|
||||||
|
|
||||||
// Test 6: Test task update (with cookies) - testing the problematic endpoint
|
|
||||||
console.log('6. Task update test (problematic endpoint):');
|
|
||||||
const taskUpdate = await makeRequest('/api/tasks/99', {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Cookie': cookies },
|
|
||||||
body: JSON.stringify({
|
|
||||||
title: 'Test Debug',
|
|
||||||
description: 'Test de debug',
|
|
||||||
priority: 'medium',
|
|
||||||
status: 'pending',
|
|
||||||
assignedTo: 'admin',
|
|
||||||
startDate: '2025-08-20',
|
|
||||||
dueDate: null
|
|
||||||
})
|
|
||||||
});
|
|
||||||
console.log(` Status: ${taskUpdate.status}`);
|
|
||||||
console.log(` Response: ${taskUpdate.data}\n`);
|
|
||||||
} else {
|
|
||||||
console.log('❌ No cookies received from login - cannot test authenticated endpoints\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Error during debug:', error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
debugProduction();
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
-- 🔍 DIAGNOSTIC PRODUCTION COMPLET - Module Avoirs
|
|
||||||
-- Copiez-collez ce script dans votre utilitaire SQL pour diagnostic précis
|
|
||||||
|
|
||||||
BEGIN;
|
|
||||||
|
|
||||||
-- SECTION 1: STRUCTURE DES TABLES
|
|
||||||
SELECT 'VÉRIFICATION STRUCTURE TABLES' AS section_title;
|
|
||||||
|
|
||||||
-- Table avoirs - structure exacte
|
|
||||||
SELECT 'STRUCTURE_AVOIRS' AS check_type, column_name, data_type, is_nullable, column_default
|
|
||||||
FROM information_schema.columns
|
|
||||||
WHERE table_name = 'avoirs' AND table_schema = 'public'
|
|
||||||
ORDER BY ordinal_position;
|
|
||||||
|
|
||||||
-- Table suppliers - structure exacte
|
|
||||||
SELECT 'STRUCTURE_SUPPLIERS' AS check_type, column_name, data_type, is_nullable
|
|
||||||
FROM information_schema.columns
|
|
||||||
WHERE table_name = 'suppliers' AND table_schema = 'public'
|
|
||||||
ORDER BY ordinal_position;
|
|
||||||
|
|
||||||
-- Table groups - structure exacte
|
|
||||||
SELECT 'STRUCTURE_GROUPS' AS check_type, column_name, data_type, is_nullable
|
|
||||||
FROM information_schema.columns
|
|
||||||
WHERE table_name = 'groups' AND table_schema = 'public'
|
|
||||||
ORDER BY ordinal_position;
|
|
||||||
|
|
||||||
-- Table users - structure exacte
|
|
||||||
SELECT 'STRUCTURE_USERS' AS check_type, column_name, data_type, is_nullable
|
|
||||||
FROM information_schema.columns
|
|
||||||
WHERE table_name = 'users' AND table_schema = 'public'
|
|
||||||
ORDER BY ordinal_position;
|
|
||||||
|
|
||||||
-- SECTION 2: CONTRAINTES
|
|
||||||
SELECT 'CONTRAINTES_AVOIRS' AS check_type, conname AS constraint_name, contype AS type,
|
|
||||||
confrelid::regclass AS referenced_table
|
|
||||||
FROM pg_constraint
|
|
||||||
WHERE conrelid = 'avoirs'::regclass;
|
|
||||||
|
|
||||||
-- SECTION 3: DONNÉES EXISTANTES
|
|
||||||
SELECT 'COMPTES_TABLES' AS check_type, 'suppliers' AS table_name, COUNT(*) AS count FROM suppliers
|
|
||||||
UNION ALL
|
|
||||||
SELECT 'COMPTES_TABLES', 'groups', COUNT(*) FROM groups
|
|
||||||
UNION ALL
|
|
||||||
SELECT 'COMPTES_TABLES', 'users', COUNT(*) FROM users
|
|
||||||
UNION ALL
|
|
||||||
SELECT 'COMPTES_TABLES', 'avoirs', COUNT(*) FROM avoirs;
|
|
||||||
|
|
||||||
-- SECTION 4: EXEMPLES DE DONNÉES (si elles existent)
|
|
||||||
-- Suppliers
|
|
||||||
SELECT 'EXEMPLES_SUPPLIERS' AS check_type, id, name FROM suppliers LIMIT 3;
|
|
||||||
|
|
||||||
-- Groups
|
|
||||||
SELECT 'EXEMPLES_GROUPS' AS check_type, id, name FROM groups LIMIT 3;
|
|
||||||
|
|
||||||
-- Users
|
|
||||||
SELECT 'EXEMPLES_USERS' AS check_type, id,
|
|
||||||
COALESCE(username, 'no_username') AS username,
|
|
||||||
role
|
|
||||||
FROM users LIMIT 3;
|
|
||||||
|
|
||||||
-- SECTION 5: TEST DE COMPATIBILITÉ
|
|
||||||
-- Test si on peut insérer avec des champs NULL
|
|
||||||
SELECT 'TEST_COMPATIBILITÉ' AS check_type,
|
|
||||||
CASE
|
|
||||||
WHEN (SELECT is_nullable FROM information_schema.columns
|
|
||||||
WHERE table_name = 'avoirs' AND column_name = 'invoice_reference') = 'YES'
|
|
||||||
THEN 'invoice_reference_NULLABLE'
|
|
||||||
ELSE 'invoice_reference_NOT_NULL'
|
|
||||||
END AS invoice_ref_status,
|
|
||||||
CASE
|
|
||||||
WHEN (SELECT is_nullable FROM information_schema.columns
|
|
||||||
WHERE table_name = 'avoirs' AND column_name = 'amount') = 'YES'
|
|
||||||
THEN 'amount_NULLABLE'
|
|
||||||
ELSE 'amount_NOT_NULL'
|
|
||||||
END AS amount_status;
|
|
||||||
|
|
||||||
ROLLBACK; -- Ne pas sauvegarder, c'est juste du diagnostic
|
|
||||||
@@ -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
|
|
||||||